Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840904d454
|
||
|
|
4a7cfca8f6
|
||
|
|
dcdd92230b
|
||
|
|
972627f641
|
||
|
|
f073a68f75
|
||
|
|
477499877d
|
||
|
|
9085601db2
|
||
|
|
0fc1994db7
|
||
|
|
6456b81d91
|
||
|
|
0842850fae
|
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
prefix: META
|
||||||
|
---
|
||||||
|
|
||||||
# Как мы ведём конвенции
|
# Как мы ведём конвенции
|
||||||
|
|
||||||
Конвенция описывает повторяющийся выбор: как называть директории, как
|
Конвенция описывает повторяющийся выбор: как называть директории, как
|
||||||
@@ -5,7 +9,7 @@
|
|||||||
принято», а не «что здесь происходит».
|
принято», а не «что здесь происходит».
|
||||||
|
|
||||||
Как записывается сама конвенция — правила, модальность, обоснования — в
|
Как записывается сама конвенция — правила, модальность, обоснования — в
|
||||||
[language.md](language.md). Здесь — про то, зачем конвенции заводятся, где
|
[LANGUAGE.md](LANGUAGE.md). Здесь — про то, зачем конвенции заводятся, где
|
||||||
живут и как соотносятся с соседними видами документов.
|
живут и как соотносятся с соседними видами документов.
|
||||||
|
|
||||||
## Область действия
|
## Область действия
|
||||||
@@ -13,7 +17,7 @@
|
|||||||
Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или
|
Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или
|
||||||
правит существующий, в каноне и в копиях репозиториев. Обязательность живёт
|
правит существующий, в каноне и в копиях репозиториев. Обязательность живёт
|
||||||
на отдельном правиле, а не на файле; шкала модальных слов — в
|
на отдельном правиле, а не на файле; шкала модальных слов — в
|
||||||
[language.md](language.md).
|
[LANGUAGE.md](LANGUAGE.md).
|
||||||
|
|
||||||
## Отличие от соседей
|
## Отличие от соседей
|
||||||
|
|
||||||
@@ -26,6 +30,14 @@
|
|||||||
- `docs/conventions/` — **правило на будущее**, применяемое многократно.
|
- `docs/conventions/` — **правило на будущее**, применяемое многократно.
|
||||||
Живой документ: правится, когда договорённость меняется.
|
Живой документ: правится, когда договорённость меняется.
|
||||||
|
|
||||||
|
## Оформление
|
||||||
|
|
||||||
|
Имя файла — kebab-case по теме: `app-directories.md`. Правилом это не
|
||||||
|
записано: обоснование сводится к «чтобы имя файла в реестре префиксов
|
||||||
|
писалось одним способом», а проверить нарушение всё равно проще глазом, чем
|
||||||
|
сформулировать норму. Номер META-16, под которым это правило существовало,
|
||||||
|
оставлен свободным и не переиспользуется.
|
||||||
|
|
||||||
## Канон и копии
|
## Канон и копии
|
||||||
|
|
||||||
Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона
|
Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона
|
||||||
@@ -42,7 +54,7 @@
|
|||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Одна конвенция — один файл
|
### META-1. Одна конвенция — один файл
|
||||||
|
|
||||||
**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор.
|
**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор.
|
||||||
|
|
||||||
@@ -52,7 +64,7 @@
|
|||||||
позже дорого: путь файла — часть адреса правила, и после разреза внешние
|
позже дорого: путь файла — часть адреса правила, и после разреза внешние
|
||||||
ссылки указывают не туда.
|
ссылки указывают не туда.
|
||||||
|
|
||||||
### R2. Конвенция заводится, когда решение принимается третий раз
|
### META-2. Конвенция заводится, когда решение принимается третий раз
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и
|
**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и
|
||||||
каждый раз чуть по-другому.
|
каждый раз чуть по-другому.
|
||||||
@@ -63,7 +75,7 @@
|
|||||||
спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть
|
спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть
|
||||||
содержание записи.
|
содержание записи.
|
||||||
|
|
||||||
### R3. Новая конвенция пишется там, где заболело
|
### META-3. Новая конвенция пишется там, где заболело
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера →
|
**СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера →
|
||||||
удаление прозы» делаются в репозитории, где случилась находка; в канон
|
удаление прозы» делаются в репозитории, где случилась находка; в канон
|
||||||
@@ -74,7 +86,7 @@
|
|||||||
платят за это все потребители сразу. Формулировка, обкатанная на одном
|
платят за это все потребители сразу. Формулировка, обкатанная на одном
|
||||||
репозитории, приезжает в канон уже с известной границей.
|
репозитории, приезжает в канон уже с известной границей.
|
||||||
|
|
||||||
### R4. В тексте конвенции нет утверждений о состоянии репозитория
|
### META-4. В тексте конвенции нет утверждений о состоянии репозитория
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без
|
**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без
|
||||||
описаний того, как сейчас устроен конкретный репозиторий.
|
описаний того, как сейчас устроен конкретный репозиторий.
|
||||||
@@ -85,7 +97,7 @@
|
|||||||
с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно
|
с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно
|
||||||
и локально, и проверяемо.
|
и локально, и проверяемо.
|
||||||
|
|
||||||
### R5. Расхождение кода с правилом — отступление, а не повод переписать правило
|
### META-5. Расхождение кода с правилом — отступление, а не повод переписать правило
|
||||||
|
|
||||||
**ДОЛЖЕН.** Правило правится, только когда неверно по существу: содержит
|
**ДОЛЖЕН.** Правило правится, только когда неверно по существу: содержит
|
||||||
фактическую ошибку, внутреннее противоречие или условие применимости,
|
фактическую ошибку, внутреннее противоречие или условие применимости,
|
||||||
@@ -96,7 +108,7 @@
|
|||||||
переписывает его снова. Направление «конвенция → код» держится ровно тем,
|
переписывает его снова. Направление «конвенция → код» держится ровно тем,
|
||||||
что факт не считается аргументом.
|
что факт не считается аргументом.
|
||||||
|
|
||||||
### R6. `ДОЛЖЕН` без механической проверки либо механизируется, либо понижается
|
### META-6. `ДОЛЖЕН` без механической проверки либо механизируется, либо понижается
|
||||||
|
|
||||||
**ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает
|
**ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает
|
||||||
машинную проверку или переводится в СЛЕДУЕТ.
|
машинную проверку или переводится в СЛЕДУЕТ.
|
||||||
@@ -106,18 +118,22 @@
|
|||||||
это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько
|
это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько
|
||||||
таких случаев обесценивает остальные ДОЛЖЕН в файле.
|
таких случаев обесценивает остальные ДОЛЖЕН в файле.
|
||||||
|
|
||||||
### R7. Факт механизации фиксируется в локальном регионе со ссылкой на номер
|
Отсюда следствие: правило, машинная проверка которого невозможна в принципе
|
||||||
|
(вкус формулировки, выбор границы, суждение о ситуации), не может быть
|
||||||
|
ДОЛЖЕН — его модальность СЛЕДУЕТ по построению, а не по слабости.
|
||||||
|
|
||||||
**ДОЛЖЕН.** Регион `механизировано` называет номер правила и конкретную
|
### META-7. Факт механизации фиксируется в локальном регионе со ссылкой на правило
|
||||||
проверку.
|
|
||||||
|
**ДОЛЖЕН.** Регион `механизировано` называет идентификатор правила и
|
||||||
|
конкретную проверку.
|
||||||
|
|
||||||
**Почему.** Механизация — состояние конкретного репозитория, канон о ней не
|
**Почему.** Механизация — состояние конкретного репозитория, канон о ней не
|
||||||
знает, а без записи следующий автор либо заведёт вторую проверку того же,
|
знает, а без записи следующий автор либо заведёт вторую проверку того же,
|
||||||
либо будет вычитывать глазами уже проверенное машиной. Без номера правила
|
либо будет вычитывать глазами уже проверенное машиной. Без идентификатора
|
||||||
читатель догадывается сам, к какому утверждению относится проверка, — и
|
читатель догадывается сам, к какому утверждению относится проверка, — и
|
||||||
догадывается по-разному.
|
догадывается по-разному.
|
||||||
|
|
||||||
### R8. Формулировка не удаляется из канона, пока механизирована не у всех
|
### META-8. Формулировка не удаляется из канона, пока механизирована не у всех
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя
|
**НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя
|
||||||
машинной проверки нет.
|
машинной проверки нет.
|
||||||
@@ -127,7 +143,12 @@
|
|||||||
говорит о прочих, поэтому удалять по факту «у нас уже проверяется» —
|
говорит о прочих, поэтому удалять по факту «у нас уже проверяется» —
|
||||||
значит чинить свой файл за чужой счёт.
|
значит чинить свой файл за чужой счёт.
|
||||||
|
|
||||||
### R9. Общая механизация разрешает удалить норму из канона
|
Списка подписчиков канон по построению не знает, поэтому факт «механизировано
|
||||||
|
у всех» устанавливается обходом репозиториев вручную — это часть работы по
|
||||||
|
удалению нормы, а не то, что можно проверить машиной (см. `LANGUAGE.md`,
|
||||||
|
состояние МЕХАНИЗИРОВАНО).
|
||||||
|
|
||||||
|
### META-9. Общая механизация разрешает удалить норму из канона
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль,
|
**ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль,
|
||||||
удаляется из канона одним `push`.
|
удаляется из канона одним `push`.
|
||||||
@@ -135,10 +156,10 @@
|
|||||||
**Почему.** Формулировка, дублирующая работающую у всех проверку,
|
**Почему.** Формулировка, дублирующая работающую у всех проверку,
|
||||||
размазывает внимание: файл на несколько сотен строк заставляет человека и
|
размазывает внимание: файл на несколько сотен строк заставляет человека и
|
||||||
агента добросовестно вычитывать тривиальное именование и не доходить до
|
агента добросовестно вычитывать тривиальное именование и не доходить до
|
||||||
формы решения. Явное разрешение нужно, чтобы R8 не читался как запрет
|
формы решения. Явное разрешение нужно, чтобы META-8 не читался как запрет
|
||||||
удалять вообще.
|
удалять вообще.
|
||||||
|
|
||||||
### R10. Обоснование не удаляется никогда
|
### META-10. Обоснование не удаляется никогда
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в
|
**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в
|
||||||
линтер.
|
линтер.
|
||||||
@@ -147,7 +168,7 @@
|
|||||||
существует. Без обоснования не видно, когда причина отпала, — проверка
|
существует. Без обоснования не видно, когда причина отпала, — проверка
|
||||||
продолжает работать по инерции, и возразить ей нечем, кроме как отключив.
|
продолжает работать по инерции, и возразить ей нечем, кроме как отключив.
|
||||||
|
|
||||||
### R11. У трудноизменяемого слоя область действия пишется явно
|
### META-11. У трудноизменяемого слоя область действия пишется явно
|
||||||
|
|
||||||
**ДОЛЖЕН.** Конвенция о схеме БД, формате хранения или раскладке директорий
|
**ДОЛЖЕН.** Конвенция о схеме БД, формате хранения или раскладке директорий
|
||||||
называет, к чему применяется: к новым таблицам и миграциям, а не к
|
называет, к чему применяется: к новым таблицам и миграциям, а не к
|
||||||
@@ -159,7 +180,7 @@
|
|||||||
состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо
|
состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо
|
||||||
молчаливый вывод, что конвенция не соблюдается совсем.
|
молчаливый вывод, что конвенция не соблюдается совсем.
|
||||||
|
|
||||||
### R12. Механизируется граница изменения, а не состояние
|
### META-12. Механизируется граница изменения, а не состояние
|
||||||
|
|
||||||
**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже
|
**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже
|
||||||
существующей схеме.
|
существующей схеме.
|
||||||
@@ -169,7 +190,7 @@
|
|||||||
новое, ради чего заводилась. Проверка границы оставляет старое в покое и
|
новое, ради чего заводилась. Проверка границы оставляет старое в покое и
|
||||||
делает новую ошибку невозможной.
|
делает новую ошибку невозможной.
|
||||||
|
|
||||||
### R13. Список отступлений трудноизменяемого слоя — постоянный
|
### META-13. Список отступлений трудноизменяемого слоя — постоянный
|
||||||
|
|
||||||
**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не
|
**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не
|
||||||
как задачи на дочистку.
|
как задачи на дочистку.
|
||||||
@@ -179,25 +200,25 @@
|
|||||||
тем, что список перестают вести, — и пропадает единственное место, где видно,
|
тем, что список перестают вести, — и пропадает единственное место, где видно,
|
||||||
где именно правило не действует.
|
где именно правило не действует.
|
||||||
|
|
||||||
### R14. Отступления перечисляются поимённо, со ссылкой на номера правил
|
### META-14. Отступления перечисляются поимённо, со ссылкой на правила
|
||||||
|
|
||||||
**ДОЛЖЕН.** В локальном регионе перечислены отступления, которые уже есть в
|
**ДОЛЖЕН.** В локальном регионе перечислены отступления, которые уже есть в
|
||||||
коде, с номером правила и причиной.
|
коде, с идентификатором правила и причиной.
|
||||||
|
|
||||||
**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить
|
**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить
|
||||||
это можно только чтением всего кода. Со ссылками отступления счётны: видно,
|
это можно только чтением всего кода. Со ссылками отступления счётны: видно,
|
||||||
сколько правил конвенции репозиторий реально не соблюдает. Пустой регион при
|
сколько правил конвенции репозиторий реально не соблюдает. Пустой регион при
|
||||||
этом почти всегда означает не отсутствие отступлений, а то, что их не искали.
|
этом почти всегда означает не отсутствие отступлений, а то, что их не искали.
|
||||||
|
|
||||||
### R15. Запись в регионе отступлений разбирается по масштабу
|
### META-15. Запись в регионе отступлений разбирается по масштабу
|
||||||
|
|
||||||
**ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано:
|
**ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано:
|
||||||
|
|
||||||
| № | Что записано | Куда идёт |
|
| № | Что записано | Куда идёт |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R15.1 | правилу не следуем в перечисленных местах, причина названа | остаётся отступлением |
|
| META-15.1 | правилу не следуем в перечисленных местах, причина названа | остаётся отступлением |
|
||||||
| R15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
|
| META-15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
|
||||||
| R15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
|
| META-15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
|
||||||
|
|
||||||
**Почему.** Отступление описывает исключение, и по нему видно, какая часть
|
**Почему.** Отступление описывает исключение, и по нему видно, какая часть
|
||||||
правила нарушена. Запись «мы это правило вообще не применяем» такой
|
правила нарушена. Запись «мы это правило вообще не применяем» такой
|
||||||
@@ -205,17 +226,7 @@
|
|||||||
правила в каноне, которую чинят один раз для всех, или лишнюю подписку,
|
правила в каноне, которую чинят один раз для всех, или лишнюю подписку,
|
||||||
где файл просто не нужен. Оставленная отступлением, она прячет обе.
|
где файл просто не нужен. Оставленная отступлением, она прячет обе.
|
||||||
|
|
||||||
### R16. Имя файла — kebab-case по теме
|
### META-17. Репо-специфичная часть «Связано» — в локальном регионе
|
||||||
|
|
||||||
**СЛЕДУЕТ.** `app-directories.md`, а не вариации регистра и разделителя.
|
|
||||||
|
|
||||||
**Почему.** Имя файла — часть глобального адреса правила
|
|
||||||
(`stack/ansible/app-directories.md R4`) и значение ключа `origin` в каждой
|
|
||||||
копии. Один способ записи избавляет от нескольких написаний одного адреса,
|
|
||||||
а ошибка в адресе обнаруживается только тем, кто по нему пришёл и ничего не
|
|
||||||
нашёл.
|
|
||||||
|
|
||||||
### R17. Репо-специфичная часть «Связано» — в локальном регионе
|
|
||||||
|
|
||||||
**ДОЛЖЕН.** Раздел «Связано» стоит в конце файла; канонические ссылки — в
|
**ДОЛЖЕН.** Раздел «Связано» стоит в конце файла; канонические ссылки — в
|
||||||
общем тексте, ссылки на ADR, код и файлы конкретного репозитория — в
|
общем тексте, ссылки на ADR, код и файлы конкретного репозитория — в
|
||||||
@@ -225,7 +236,7 @@
|
|||||||
чужой файл у них битая с первого дня. Регион исключён из сравнения, поэтому
|
чужой файл у них битая с первого дня. Регион исключён из сравнения, поэтому
|
||||||
та же ссылка внутри него никого не задевает и не даёт вечного шума в `diff`.
|
та же ссылка внутри него никого не задевает и не даёт вечного шума в `diff`.
|
||||||
|
|
||||||
### R18. README директории перечисляет конвенции с однострочным описанием
|
### META-18. README директории перечисляет конвенции с однострочным описанием
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он.
|
**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он.
|
||||||
|
|
||||||
@@ -233,15 +244,16 @@
|
|||||||
«какая из них про мой случай» решается открыванием каждой. Ценой в десяток
|
«какая из них про мой случай» решается открыванием каждой. Ценой в десяток
|
||||||
файлов это означает, что не открывают ни одной.
|
файлов это означает, что не открывают ни одной.
|
||||||
|
|
||||||
### R19. Короткие инварианты дублируются в точку входа агента
|
### META-19. Короткие инварианты дублируются в точку входа агента
|
||||||
|
|
||||||
**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его
|
**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его
|
||||||
номером; детали остаются в конвенции.
|
идентификатором; детали остаются в конвенции.
|
||||||
|
|
||||||
**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только
|
**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только
|
||||||
если его туда отправили, — а безусловно он читает точку входа. Строка с
|
если его туда отправили, — а безусловно он читает точку входа. Строка с
|
||||||
номером служит и напоминанием, и адресом, по которому за подробностями
|
идентификатором служит и напоминанием, и адресом, по которому за
|
||||||
идут; перенос деталей туда же вернул бы задачу поддержки двух текстов.
|
подробностями идут; перенос деталей туда же вернул бы задачу поддержки двух
|
||||||
|
текстов.
|
||||||
|
|
||||||
<!-- local:точки-входа -->
|
<!-- local:точки-входа -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
Не ради строгости. Три конкретные вещи, которые без адресуемых правил не
|
Не ради строгости. Три конкретные вещи, которые без адресуемых правил не
|
||||||
работают:
|
работают:
|
||||||
|
|
||||||
- **Механизация.** Регион `механизировано` должен говорить «правило R4
|
- **Механизация.** Регион `механизировано` должен говорить «правило
|
||||||
проверяет `archrules`», а не «`AUTOINCREMENT` в новых миграциях —
|
`MIGR-4` проверяет `archrules`», а не «`AUTOINCREMENT` в новых миграциях —
|
||||||
`archrules`»: во втором случае читатель сам догадывается, к какому
|
`archrules`»: во втором случае читатель сам догадывается, к какому
|
||||||
утверждению это относится, и догадывается по-разному.
|
утверждению это относится, и догадывается по-разному.
|
||||||
- **Отступления.** «У нас не так» бесполезно, пока не сказано, что именно
|
- **Отступления.** «У нас не так» бесполезно, пока не сказано, что именно
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
## Единица — правило
|
## Единица — правило
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
### R5. Разбор внешнего идентификатора на границе
|
### KEYS-5. Разбор внешнего идентификатора на границе
|
||||||
|
|
||||||
**ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса
|
**ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса
|
||||||
к базе.
|
к базе.
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
существующую запись — отладка такого случая стоит дороже, чем сам разбор.
|
существующую запись — отладка такого случая стоит дороже, чем сам разбор.
|
||||||
```
|
```
|
||||||
|
|
||||||
Четыре обязательные части: **номер**, **заголовок**, **модальность с
|
Четыре обязательные части: **идентификатор**, **заголовок**, **модальность
|
||||||
нормой**, **почему**. Норма — одна фраза; если в неё не влезает, это два
|
с нормой**, **почему**. Норма — одна фраза; если в неё не влезает, это два
|
||||||
правила.
|
правила.
|
||||||
|
|
||||||
## Правило без «почему» не принимается
|
## Правило без «почему» не принимается
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
Модальность живёт на **правиле**, а не на файле. Прежний файловый статус
|
Модальность живёт на **правиле**, а не на файле. Прежний файловый статус
|
||||||
(`status: рекомендуемая` / `обязательная` в шапке) отменён: он неизбежно
|
(`status: рекомендуемая` / `обязательная` в шапке) отменён: он неизбежно
|
||||||
врал, потому что один файл смешивает жёсткие требования с советами. В шапке
|
врал, потому что один файл смешивает жёсткие требования с советами. В шапке
|
||||||
остаются только `extends` и служебные ключи копии.
|
остаются только `prefix`, `extends` и служебные ключи копии.
|
||||||
|
|
||||||
Мы **не используем SHALL и прочие английские ключевые слова**. Они заняты
|
Мы **не используем SHALL и прочие английские ключевые слова**. Они заняты
|
||||||
спецификациями (OpenSpec), и общий словарь стирал бы границу «конвенция —
|
спецификациями (OpenSpec), и общий словарь стирал бы границу «конвенция —
|
||||||
@@ -87,13 +87,26 @@
|
|||||||
|
|
||||||
## Идентификаторы
|
## Идентификаторы
|
||||||
|
|
||||||
- Формат — `R<номер>`, сквозная нумерация внутри файла, начиная с `R1`.
|
- Формат — `<ПРЕФИКС>-<номер>`: `KEYS-5`, `SLOG-27`. Префикс принадлежит
|
||||||
- Строка таблицы, если на неё нужно ссылаться отдельно, — `R5.1`, `R5.2`.
|
файлу, нумерация внутри файла сквозная и начинается с единицы.
|
||||||
- **Номера стабильны и не переиспользуются.** Удалённое правило оставляет
|
- Строка таблицы, если на неё нужно ссылаться отдельно, — `KEYS-5.1`,
|
||||||
дыру в нумерации; занимать её новым правилом нельзя — иначе ссылка из
|
`KEYS-5.2`.
|
||||||
чужого репозитория начнёт указывать на другое утверждение.
|
- **Идентификатор глобален.** Префикс уникален по всему канону, поэтому
|
||||||
- Глобальный адрес — путь файла плюс номер: `arch/db-identifiers.md R5`.
|
путь файла в ссылке не нужен: `KEYS-5` адресует правило одинаково изнутри
|
||||||
В пределах одного файла достаточно `R5`.
|
файла, из соседней конвенции и из чужого репозитория. В собранной копии
|
||||||
|
слои разных осей лежат в одном документе, так что ссылка на базовый слой
|
||||||
|
из языкового вообще никуда не ведёт — правило рядом.
|
||||||
|
- **Идентификаторы стабильны и не переиспользуются.** Удалённое правило
|
||||||
|
оставляет дыру в нумерации; занимать её новым нельзя — иначе ссылка из
|
||||||
|
чужого репозитория начнёт указывать на другое утверждение. То же
|
||||||
|
относится к префиксам: выбывшие хранит `prefixes.toml`.
|
||||||
|
- Префикс **выбирается под файл, а не выводится по формуле**: он нужен,
|
||||||
|
чтобы по нему искать, а не чтобы его разбирать. Выводимый префикс вдобавок
|
||||||
|
привязал бы идентификатор к таксономии, которую канон перестраивает, и
|
||||||
|
упёрся бы в потолок из числа букв алфавита.
|
||||||
|
- Перенос правила в другой файл — смысловое изменение, а не переименование:
|
||||||
|
новый файл означает новый префикс и новую нумерацию. Переезд самого файла
|
||||||
|
между осями идентификаторы не трогает.
|
||||||
|
|
||||||
Порядок правил в файле выбирается по читаемости, не по номерам: номер — это
|
Порядок правил в файле выбирается по читаемости, не по номерам: номер — это
|
||||||
идентификатор, а не позиция.
|
идентификатор, а не позиция.
|
||||||
@@ -105,7 +118,7 @@
|
|||||||
чтобы оно не выглядело недописанным, место нормы занимает отметка:
|
чтобы оно не выглядело недописанным, место нормы занимает отметка:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
### R6. Дефолтов времени в схеме БД нет
|
### MIGR-6. Дефолтов времени в схеме БД нет
|
||||||
|
|
||||||
**МЕХАНИЗИРОВАНО.** Проверяется общим правилом линтера; формулировка
|
**МЕХАНИЗИРОВАНО.** Проверяется общим правилом линтера; формулировка
|
||||||
удалена, потому что дублировала работающую проверку.
|
удалена, потому что дублировала работающую проверку.
|
||||||
@@ -113,7 +126,7 @@
|
|||||||
**Почему.** Дефолт превращает забытую вставку в тихо работающий код…
|
**Почему.** Дефолт превращает забытую вставку в тихо работающий код…
|
||||||
```
|
```
|
||||||
|
|
||||||
- Номер и заголовок сохраняются: ссылки из репозиториев продолжают
|
- Идентификатор и заголовок сохраняются: ссылки из репозиториев продолжают
|
||||||
указывать на то же утверждение.
|
указывать на то же утверждение.
|
||||||
- «Почему» остаётся навсегда — линтер сообщает, что нарушено, но не
|
- «Почему» остаётся навсегда — линтер сообщает, что нарушено, но не
|
||||||
сообщает, зачем правило существует, и без обоснования нельзя понять,
|
сообщает, зачем правило существует, и без обоснования нельзя понять,
|
||||||
@@ -175,11 +188,11 @@ AND тик фонового цикла упал по той же причине
|
|||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
R2, R4 — `internal/archrules` (проверяются в новых миграциях).
|
MIGR-2, MIGR-4 — `internal/archrules` (проверяются в новых миграциях).
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
<!-- local:отступления -->
|
<!-- local:отступления -->
|
||||||
R6 — не соблюдается в легаси-таблицах `show_history`, `queue`: составные
|
MIGR-6 — не соблюдается в легаси-таблицах `show_history`, `queue`: составные
|
||||||
ключи там появились до конвенции, переписывание требует миграции данных.
|
ключи там появились до конвенции, переписывание требует миграции данных.
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
```
|
```
|
||||||
@@ -191,12 +204,15 @@ R6 — не соблюдается в легаси-таблицах `show_histor
|
|||||||
|
|
||||||
Сейчас не реализовано; список — на будущее для `conv`:
|
Сейчас не реализовано; список — на будущее для `conv`:
|
||||||
|
|
||||||
|
- префикс в шапке файла совпадает с реестром, состоит из четырёх заглавных
|
||||||
|
латинских букв и не значится в списке выбывших;
|
||||||
|
- заголовки правил файла используют только его собственный префикс;
|
||||||
- номера уникальны внутри файла и не имеют пропусков вниз (новое правило
|
- номера уникальны внутри файла и не имеют пропусков вниз (новое правило
|
||||||
берёт следующий свободный, а не первый освободившийся);
|
берёт следующий свободный, а не первый освободившийся);
|
||||||
- у каждого `### R<n>` есть модальное слово (или отметка МЕХАНИЗИРОВАНО) и
|
- у каждого `### <ПРЕФИКС>-<n>` есть модальное слово (или отметка
|
||||||
блок «Почему»;
|
МЕХАНИЗИРОВАНО) и блок «Почему»;
|
||||||
- ссылки вида `R<n>` в локальных регионах копии указывают на правила,
|
- ссылки вида `<ПРЕФИКС>-<n>` — хоть в тексте канона, хоть в локальных
|
||||||
которые в каноне ещё существуют;
|
регионах копии — указывают на правила, которые ещё существуют;
|
||||||
- модальные слова не встречаются вне правил.
|
- модальные слова не встречаются вне правил.
|
||||||
|
|
||||||
## Порядок перевода
|
## Порядок перевода
|
||||||
@@ -5,6 +5,21 @@
|
|||||||
`ansible-roles`: канон не источник истины во время работы, а лавка, из
|
`ansible-roles`: канон не источник истины во время работы, а лавка, из
|
||||||
которой берут и в которую возвращают улучшения.
|
которой берут и в которую возвращают улучшения.
|
||||||
|
|
||||||
|
Сами конвенции лежат в `conventions/`, обвязка — в корне:
|
||||||
|
|
||||||
|
| Файл | Что описывает |
|
||||||
|
|---|---|
|
||||||
|
| `README.md` | устройство канона, оси, синхронизация, жизненный цикл |
|
||||||
|
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, «Почему» |
|
||||||
|
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
|
||||||
|
| `prefixes.toml` | реестр префиксов правил |
|
||||||
|
| `conv` | синхронизация копий |
|
||||||
|
|
||||||
|
Обвязка живёт только в каноне и в репозитории не оказывается — `conv`
|
||||||
|
синхронизирует лишь содержимое `conventions/`. Пока это осознанное
|
||||||
|
ограничение: копия конвенции ссылается на `LANGUAGE.md` как на внешний
|
||||||
|
документ.
|
||||||
|
|
||||||
Правило то же, что у ролей: **деплоится и читается только то, что лежит в
|
Правило то же, что у ролей: **деплоится и читается только то, что лежит в
|
||||||
git репозитория**. Канон никем не подключается на лету.
|
git репозитория**. Канон никем не подключается на лету.
|
||||||
|
|
||||||
@@ -30,12 +45,18 @@ git репозитория**. Канон никем не подключаетс
|
|||||||
## Оси
|
## Оси
|
||||||
|
|
||||||
```
|
```
|
||||||
common/ как вести сами конвенции
|
conventions/
|
||||||
arch/ решения, переживающие смену языка и инструментов
|
arch/ решения, переживающие смену языка и инструментов
|
||||||
lang/<язык>/ как решение реализуется и механизируется в языке
|
lang/<язык>/ как решение реализуется и механизируется в языке
|
||||||
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
|
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Пути **файлов** даются относительно `conventions/`: `arch/db-identifiers.md`,
|
||||||
|
а не `conventions/arch/…` — так же, как они лягут в `docs/conventions/`
|
||||||
|
репозитория. На **правила** ссылаются идентификатором без пути: `KEYS-5`.
|
||||||
|
Префикс уникален по всему канону (реестр — `prefixes.toml`), поэтому
|
||||||
|
идентификатор не зависит от того, на какой оси файл лежит сегодня.
|
||||||
|
|
||||||
Тест — по тому, замена чего убивает правило:
|
Тест — по тому, замена чего убивает правило:
|
||||||
|
|
||||||
> Умирает при смене **языка** → `lang/`. Умирает при смене **инструмента,
|
> Умирает при смене **языка** → `lang/`. Умирает при смене **инструмента,
|
||||||
@@ -59,9 +80,22 @@ stack/<стек>/ привязка к инструменту, хранили
|
|||||||
пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая
|
пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая
|
||||||
работа.
|
работа.
|
||||||
|
|
||||||
|
## Префиксы
|
||||||
|
|
||||||
|
Каждый файл канона объявляет в шапке свой префикс правил:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
prefix: KEYS
|
||||||
|
```
|
||||||
|
|
||||||
|
Четыре заглавные латинские буквы, уникальные по всему канону; реестр —
|
||||||
|
[`prefixes.toml`](prefixes.toml). Префикс выбирается под файл, а не выводится
|
||||||
|
по формуле, и не переиспользуется никогда. Правила адресуются идентификатором
|
||||||
|
`KEYS-5` — без пути к файлу. Подробности формы — `LANGUAGE.md`.
|
||||||
|
|
||||||
## Расширение
|
## Расширение
|
||||||
|
|
||||||
Файл в `lang/` или `stack/` может объявить в шапке:
|
Файл в `lang/` или `stack/` может объявить в шапке ещё и базу:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
extends: arch/db-identifiers.md
|
extends: arch/db-identifiers.md
|
||||||
@@ -91,7 +125,7 @@ local: нет # или: чем и почему разошлись
|
|||||||
`origin_hash` — контент-отпечаток, а не git-SHA. Он позволяет отличать
|
`origin_hash` — контент-отпечаток, а не git-SHA. Он позволяет отличать
|
||||||
«канон обновился» от «изменено локально»; без него `status` умеет только
|
«канон обновился» от «изменено локально»; без него `status` умеет только
|
||||||
«differs», а такой отчёт быстро перестают читать. Прочие ключи шапки
|
«differs», а такой отчёт быстро перестают читать. Прочие ключи шапки
|
||||||
(`status`, `extends`) — часть документа: они сравниваются наравне с телом.
|
(`prefix`, `extends`) — часть документа: они сравниваются наравне с телом.
|
||||||
|
|
||||||
**Локальные регионы** — куски, принадлежащие репозиторию по определению.
|
**Локальные регионы** — куски, принадлежащие репозиторию по определению.
|
||||||
Из сравнения исключаются, поэтому вечного шума в `diff` не дают:
|
Из сравнения исключаются, поэтому вечного шума в `diff` не дают:
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""conv — синхронизация конвенций между каноном и репозиторием.
|
"""conv — синхронизация конвенций между каноном и репозиторием.
|
||||||
|
|
||||||
Канон — эта директория. Репозиторий держит закоммиченные копии нужных
|
Канон — директория conventions/ рядом с этим скриптом. Репозиторий держит
|
||||||
конвенций в docs/conventions/, повторяя структуру канона. Копия — источник
|
закоммиченные копии нужных конвенций в docs/conventions/, повторяя её
|
||||||
правды для репозитория; канон — лавка, из которой берут.
|
структуру. Копия — источник правды для репозитория; канон — лавка, из
|
||||||
|
которой берут. Пути в origin даются относительно conventions/.
|
||||||
|
|
||||||
Служебная разметка копии:
|
Служебная разметка копии:
|
||||||
|
|
||||||
@@ -50,8 +51,8 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NoReturn
|
from typing import NoReturn
|
||||||
|
|
||||||
CANON = Path(__file__).resolve().parent
|
CANON = Path(__file__).resolve().parent / "conventions"
|
||||||
CANON_TREES = ("common", "arch", "lang", "stack")
|
CANON_TREES = ("arch", "lang", "stack")
|
||||||
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
|
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
|
||||||
DEFAULT_DIR = "docs/conventions"
|
DEFAULT_DIR = "docs/conventions"
|
||||||
ENC = "utf-8"
|
ENC = "utf-8"
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
---
|
||||||
|
prefix: DIRS
|
||||||
|
---
|
||||||
|
|
||||||
# Категории директорий приложения
|
# Категории директорий приложения
|
||||||
|
|
||||||
Всё, что приложение пишет на диск, делится на три категории по принципу
|
Всё, что приложение пишет на диск, делится на три категории по принципу
|
||||||
создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу
|
создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу
|
||||||
отвечает на два вопроса, которые иначе выясняются чтением кода приложения:
|
отвечает на два вопроса, которые иначе выясняются чтением кода приложения:
|
||||||
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
|
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
|
||||||
механически выводится состав бэкапа. Форма записи — `common/language.md`.
|
механически выводится состав бэкапа. Форма записи — `LANGUAGE.md`.
|
||||||
|
|
||||||
## Область действия
|
## Область действия
|
||||||
|
|
||||||
@@ -16,32 +20,32 @@
|
|||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Записываемые пути разложены по трём категориям
|
### DIRS-1. Записываемые пути разложены по трём категориям
|
||||||
|
|
||||||
**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой,
|
**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой,
|
||||||
относится к одной из трёх категорий:
|
относится к одной из трёх категорий:
|
||||||
|
|
||||||
| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе |
|
| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
|
| DIRS-1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
|
||||||
| R1.2 | данные | `data/` | приложение | невосполнима | да |
|
| DIRS-1.2 | данные | `data/` | приложение | невосполнима | да |
|
||||||
| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
|
| DIRS-1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
|
||||||
|
|
||||||
Имена в таблице — умолчание для случая «одна директория на категорию».
|
Имена в таблице — умолчание для случая «одна директория на категорию».
|
||||||
|
|
||||||
**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно
|
**Почему.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно
|
||||||
снести при нехватке места, что переживает переезд на другой диск —
|
снести при нехватке места, что переживает переезд на другой диск —
|
||||||
читаются из категории, а не выясняются по коду приложения. Без единой
|
читаются из категории, а не выясняются по коду приложения. Без единой
|
||||||
классификации каждое такое решение принимается заново и каждый раз чуть
|
классификации каждое такое решение принимается заново и каждый раз чуть
|
||||||
по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места,
|
по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места,
|
||||||
потерянные данные не стоят ничего, потому что их больше нет.
|
потерянные данные не стоят ничего, потому что их больше нет.
|
||||||
|
|
||||||
### R2. Категория может состоять из нескольких директорий
|
### DIRS-2. Категория может состоять из нескольких директорий
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
|
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
|
||||||
принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
||||||
|
|
||||||
**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно
|
**Почему.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно
|
||||||
три директории». Крупные файлы отделяют от базы, чтобы двигать их между
|
три директории». Крупные файлы отделяют от базы, чтобы двигать их между
|
||||||
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
|
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
|
||||||
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном
|
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном
|
||||||
@@ -49,15 +53,15 @@
|
|||||||
задавать именем ровно поэтому: имён в категории несколько, и выбираются они
|
задавать именем ровно поэтому: имён в категории несколько, и выбираются они
|
||||||
по содержимому.
|
по содержимому.
|
||||||
|
|
||||||
### R3. Данные и кеш разделяются по тесту на пересоздание
|
### DIRS-3. Данные и кеш разделяются по тесту на пересоздание
|
||||||
|
|
||||||
**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому:
|
**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому:
|
||||||
|
|
||||||
| № | Что лежит | Категория |
|
| № | Что лежит | Категория |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
|
| DIRS-3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
|
||||||
| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
|
| DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
|
||||||
| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
|
| DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
|
||||||
|
|
||||||
**Почему.** Без внешнего теста граница проводится по ощущению «жалко
|
**Почему.** Без внешнего теста граница проводится по ощущению «жалко
|
||||||
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
|
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
|
||||||
@@ -67,7 +71,7 @@
|
|||||||
обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не
|
обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не
|
||||||
при попытке восстановить.
|
при попытке восстановить.
|
||||||
|
|
||||||
### R4. В бэкап идут данные, и только они
|
### DIRS-4. В бэкап идут данные, и только они
|
||||||
|
|
||||||
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
|
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
|
||||||
кеш — нет.
|
кеш — нет.
|
||||||
@@ -79,12 +83,16 @@
|
|||||||
Ошибка в другую сторону дороже: директория данных, не попавшая в список,
|
Ошибка в другую сторону дороже: директория данных, не попавшая в список,
|
||||||
обнаруживается в единственный момент, когда исправить её уже нечем.
|
обнаруживается в единственный момент, когда исправить её уже нечем.
|
||||||
|
|
||||||
### R5. Список бэкапа ссылается на те же пути, что и создание директорий
|
### DIRS-5. Список бэкапа ссылается на те же пути, что и создание директорий
|
||||||
|
|
||||||
**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же
|
**ДОЛЖЕН.** Список выводится из категорий по DIRS-4 и ссылается на те же
|
||||||
объявления путей, по которым директории создаются, а не набирается
|
**объявления путей**, по которым директории создаются, а не набирается
|
||||||
независимо.
|
независимо.
|
||||||
|
|
||||||
|
Объявление пути — то единственное место, где путь директории записан
|
||||||
|
буквально: переменная деплоя, константа, поле конфигурации. Всё остальное
|
||||||
|
на него ссылается.
|
||||||
|
|
||||||
**Почему.** Правило вывода механическое, но применяет его человек или
|
**Почему.** Правило вывода механическое, но применяет его человек или
|
||||||
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
|
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
|
||||||
невозможным: переименование директории отражается в обоих местах сразу.
|
невозможным: переименование директории отражается в обоих местах сразу.
|
||||||
@@ -92,25 +100,25 @@
|
|||||||
на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что
|
на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что
|
||||||
нужно, проявляется при восстановлении.
|
нужно, проявляется при восстановлении.
|
||||||
|
|
||||||
### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
|
### DIRS-6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
|
||||||
|
|
||||||
**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске:
|
**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске:
|
||||||
|
|
||||||
| № | Данные | В бэкап |
|
| № | Данные | В бэкап |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R6.1 | файлы самодостаточны на любой момент времени | копированием |
|
| DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием |
|
||||||
| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
|
| DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
|
||||||
|
|
||||||
**Почему.** Файловый снапшот работающей СУБД не гарантирует
|
**Почему.** Файловый снапшот работающей СУБД не гарантирует
|
||||||
консистентности: скопированный каталог может не восстановиться, и узнают
|
консистентности: скопированный каталог может не восстановиться, и узнают
|
||||||
об этом при восстановлении. Директория дампов — тоже данные, просто
|
об этом при восстановлении. Директория дампов — тоже данные, просто
|
||||||
производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из
|
производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из
|
||||||
списка при этом исключается: он удваивает объём снапшота и добавляет к
|
списка при этом исключается: он удваивает объём снапшота и добавляет к
|
||||||
надёжной копии заведомо ненадёжную.
|
надёжной копии заведомо ненадёжную.
|
||||||
|
|
||||||
### R7. Способ выбирается при заведении приложения
|
### DIRS-7. Способ выбирается при заведении приложения
|
||||||
|
|
||||||
**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда
|
**ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда
|
||||||
приложение заводят.
|
приложение заводят.
|
||||||
|
|
||||||
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не
|
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не
|
||||||
@@ -119,7 +127,7 @@
|
|||||||
первой неудачной попытки восстановления, то есть тогда, когда данных уже
|
первой неудачной попытки восстановления, то есть тогда, когда данных уже
|
||||||
нет.
|
нет.
|
||||||
|
|
||||||
### R8. Приложение разводит записываемые пути по категориям
|
### DIRS-8. Приложение разводит записываемые пути по категориям
|
||||||
|
|
||||||
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
|
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
|
||||||
кеша, а не один каталог на всё.
|
кеша, а не один каталог на всё.
|
||||||
@@ -131,12 +139,12 @@
|
|||||||
появляется молча. Приложение, которое не умеет разделять, тем самым
|
появляется молча. Приложение, которое не умеет разделять, тем самым
|
||||||
дефектно; раскладка под этот дефект не подстраивается.
|
дефектно; раскладка под этот дефект не подстраивается.
|
||||||
|
|
||||||
### R9. Приложение не пишет в директорию конфигурации
|
### DIRS-9. Приложение не пишет в директорию конфигурации
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
|
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
|
||||||
конфигурации.
|
конфигурации.
|
||||||
|
|
||||||
**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому
|
**Почему.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому
|
||||||
всё, что приложение туда записало, следующий деплой затирает без
|
всё, что приложение туда записало, следующий деплой затирает без
|
||||||
предупреждения. Вдобавок директория конфигурации может быть подключена
|
предупреждения. Вдобавок директория конфигурации может быть подключена
|
||||||
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
|
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
---
|
||||||
|
prefix: CONF
|
||||||
|
---
|
||||||
|
|
||||||
# Конфигурация приложения
|
# Конфигурация приложения
|
||||||
|
|
||||||
Как устроена конфигурация: где лежит, как попадает в процесс, что с
|
Как устроена конфигурация: где лежит, как попадает в процесс, что с
|
||||||
секретами и когда падает. Форма записи — `common/language.md`.
|
секретами и когда падает. Форма записи — `LANGUAGE.md`.
|
||||||
|
|
||||||
## Область действия
|
## Область действия
|
||||||
|
|
||||||
@@ -12,7 +16,7 @@
|
|||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Конфигурация — файл, а не окружение
|
### CONF-1. Конфигурация — файл, а не окружение
|
||||||
|
|
||||||
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
|
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
|
||||||
окружения источником конфигурации не служат.
|
окружения источником конфигурации не служат.
|
||||||
@@ -36,17 +40,17 @@
|
|||||||
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
|
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
|
||||||
`0600`.
|
`0600`.
|
||||||
|
|
||||||
### R2. Формат конфигурации — текстовый, с секциями и комментариями
|
### CONF-2. Формат конфигурации — текстовый, с секциями и комментариями
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
|
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
|
||||||
|
|
||||||
**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг
|
**Почему.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг
|
||||||
вообще читают; формат, в котором комментарий негде разместить, делает R9
|
вообще читают; формат, в котором комментарий негде разместить, делает CONF-9
|
||||||
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
|
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
|
||||||
плоский список пар такой возможности не даёт и возвращает нас к тем же
|
плоский список пар такой возможности не даёт и возвращает нас к тем же
|
||||||
свойствам, из-за которых отвергнуто окружение (R1).
|
свойствам, из-за которых отвергнуто окружение (CONF-1).
|
||||||
|
|
||||||
### R3. Имя файла фиксировано, путь переопределяется опцией
|
### CONF-3. Имя файла фиксировано, путь переопределяется опцией
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
|
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
|
||||||
задаётся опцией командной строки.
|
задаётся опцией командной строки.
|
||||||
@@ -55,18 +59,40 @@
|
|||||||
контейнере и на сервере, и способ запуска не приходится помнить отдельно
|
контейнере и на сервере, и способ запуска не приходится помнить отдельно
|
||||||
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
|
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
|
||||||
(тесты, второй инстанс): без неё их разводят переменной окружения — тем
|
(тесты, второй инстанс): без неё их разводят переменной окружения — тем
|
||||||
самым каналом, который закрывает R1.
|
самым каналом, который закрывает CONF-1.
|
||||||
|
|
||||||
### R4. В репозитории лежит образец, а не рабочий конфиг
|
### CONF-20. Отсутствие файла конфигурации — ошибка старта
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Если файла нет ни по пути из опции, ни по имени по умолчанию в
|
||||||
|
рабочей директории (CONF-3), приложение не стартует: сообщение называет
|
||||||
|
искомый путь, код возврата ненулевой.
|
||||||
|
|
||||||
|
**Почему.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что
|
||||||
|
развёртывание не довело работу до конца, а не что приложение попросили
|
||||||
|
работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал
|
||||||
|
**неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается
|
||||||
|
чаще всего.
|
||||||
|
|
||||||
|
Старт без файла ничего не спасает: у приложения с обязательными полями или
|
||||||
|
секретами всё равно упадёт валидация (CONF-18), только вместо одного сообщения
|
||||||
|
«нет `config.toml`» получится каскад «поле пусто», за которым настоящая
|
||||||
|
причина — деплой не отрендерил файл — не видна.
|
||||||
|
|
||||||
|
Приложение, которое запускается вообще без конфигурации, этой конвенцией не
|
||||||
|
описывается: это отдельный случай и отдельная конвенция.
|
||||||
|
|
||||||
|
<!-- local:проверки -->
|
||||||
|
<!-- /local -->
|
||||||
|
### CONF-4. В репозитории лежит образец, а не рабочий конфиг
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
|
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
|
||||||
|
|
||||||
**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет,
|
**Почему.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет,
|
||||||
попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
|
попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
|
||||||
закоммиченный конфиг конкретной среды становится вторым источником истины:
|
закоммиченный конфиг конкретной среды становится вторым источником истины:
|
||||||
он расходится с тем, что реально развёрнуто, и расходится молча.
|
он расходится с тем, что реально развёрнуто, и расходится молча.
|
||||||
|
|
||||||
### R5. Конфиг разбирается один раз при старте
|
### CONF-5. Конфиг разбирается один раз при старте
|
||||||
|
|
||||||
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
|
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
|
||||||
файла конфигурации в бизнес-коде нет.
|
файла конфигурации в бизнес-коде нет.
|
||||||
@@ -74,10 +100,10 @@
|
|||||||
**Почему.** Второе место чтения — это второй момент времени: две части кода
|
**Почему.** Второе место чтения — это второй момент времени: две части кода
|
||||||
начинают видеть разные значения одного параметра, и расхождение не
|
начинают видеть разные значения одного параметра, и расхождение не
|
||||||
воспроизводится, потому что зависит от того, когда файл потрогали.
|
воспроизводится, потому что зависит от того, когда файл потрогали.
|
||||||
Типизированная структура вдобавок переносит ошибку формата в старт (R17),
|
Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17),
|
||||||
где она видна сразу, а не в первый вызов ветки, которая это поле читает.
|
где она видна сразу, а не в первый вызов ветки, которая это поле читает.
|
||||||
|
|
||||||
### R6. Конфиг неизменяем после старта
|
### CONF-6. Конфиг неизменяем после старта
|
||||||
|
|
||||||
**ДОЛЖЕН.** Смена параметров — рестарт процесса.
|
**ДОЛЖЕН.** Смена параметров — рестарт процесса.
|
||||||
|
|
||||||
@@ -89,7 +115,7 @@
|
|||||||
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
|
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
|
||||||
умолчание.
|
умолчание.
|
||||||
|
|
||||||
### R7. Умолчания живут в коде
|
### CONF-7. Умолчания живут в коде
|
||||||
|
|
||||||
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
|
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
|
||||||
|
|
||||||
@@ -98,17 +124,17 @@
|
|||||||
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
|
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
|
||||||
поведение для неполного конфига и одно место, где это значение меняется.
|
поведение для неполного конфига и одно место, где это значение меняется.
|
||||||
|
|
||||||
### R8. Образец перечисляет все поля
|
### CONF-8. Образец перечисляет все поля
|
||||||
|
|
||||||
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
|
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
|
||||||
которых есть умолчание (R7).
|
которых есть умолчание (CONF-7).
|
||||||
|
|
||||||
**Почему.** Поле, живущее только в коде, для читателя конфига не
|
**Почему.** Поле, живущее только в коде, для читателя конфига не
|
||||||
существует: он не знает, что параметр вообще можно менять, и добивается
|
существует: он не знает, что параметр вообще можно менять, и добивается
|
||||||
нужного поведения обходным путём. Полнота образца — цена, которой R7
|
нужного поведения обходным путём. Полнота образца — цена, которой CONF-7
|
||||||
покупает себе видимость.
|
покупает себе видимость.
|
||||||
|
|
||||||
### R9. У каждого поля образца есть комментарий
|
### CONF-9. У каждого поля образца есть комментарий
|
||||||
|
|
||||||
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
|
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
|
||||||
|
|
||||||
@@ -123,35 +149,35 @@
|
|||||||
дают валидное значение и работающий процесс, а ошибка обнаруживается по
|
дают валидное значение и работающий процесс, а ошибка обнаруживается по
|
||||||
последствиям — таймаут в тысячу раз не тот.
|
последствиям — таймаут в тысячу раз не тот.
|
||||||
|
|
||||||
### R10. Обязательность полей определяется дискриминатором `type`
|
### CONF-10. Обязательность полей определяется дискриминатором `type`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
|
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
|
||||||
бекенда или внешнего сервиса), валидация идёт по его значению:
|
бекенда или внешнего сервиса), валидация идёт по его значению:
|
||||||
|
|
||||||
| № | Значение `type` | Валидация |
|
| № | Значение `type` | Валидация |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
|
| CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
|
||||||
| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
|
| CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
|
||||||
|
|
||||||
**Почему.** Фиксированный на секцию набор обязательных полей оставляет
|
**Почему.** Фиксированный на секцию набор обязательных полей оставляет
|
||||||
выбор из двух плохих: заполнять поля бекенда, который не используется, или
|
выбор из двух плохих: заполнять поля бекенда, который не используется, или
|
||||||
не проверять обязательность вовсе — то есть выключить валидацию ровно там,
|
не проверять обязательность вовсе — то есть выключить валидацию ровно там,
|
||||||
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
|
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
|
||||||
значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от
|
значений (CONF-10.2) нужно потому, что опечатка в `type` иначе неотличима от
|
||||||
неподдерживаемого варианта, и за списком приходится идти в код.
|
неподдерживаемого варианта, и за списком приходится идти в код.
|
||||||
|
|
||||||
### R11. Образец показывает все варианты `type`
|
### CONF-11. Образец показывает все варианты `type`
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
|
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
|
||||||
альтернативные — блоками-комментариями ниже, каждый со своим описанием
|
альтернативные — блоками-комментариями ниже, каждый со своим описанием
|
||||||
полей.
|
полей.
|
||||||
|
|
||||||
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец
|
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец
|
||||||
теряет свойство справочника (R8, R9) ровно на той секции, где выбор
|
теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор
|
||||||
действительно есть. Закомментированный блок вдобавок переключается правкой
|
действительно есть. Закомментированный блок вдобавок переключается правкой
|
||||||
на месте, а не сборкой секции с нуля по документации.
|
на месте, а не сборкой секции с нуля по документации.
|
||||||
|
|
||||||
### R12. Секреты в конфиг приносит деплой
|
### CONF-12. Секреты в конфиг приносит деплой
|
||||||
|
|
||||||
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
|
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
|
||||||
отдельного слоя секретов в приложении нет.
|
отдельного слоя секретов в приложении нет.
|
||||||
@@ -163,27 +189,28 @@
|
|||||||
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
|
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
|
||||||
особенного.
|
особенного.
|
||||||
|
|
||||||
### R13. Рендеренный конфиг — `0600` и владелец-рантайм
|
### CONF-13. Рендеренный конфиг — `0600` и владелец-рантайм
|
||||||
|
|
||||||
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
|
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
|
||||||
работает процесс.
|
работает процесс.
|
||||||
|
|
||||||
**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на
|
**Почему.** После CONF-1 и CONF-12 файл конфигурации — единственная
|
||||||
которой секреты лежат, и весь довод «файл вместо окружения» держится на его
|
поверхность, на которой секреты лежат, и весь довод «файл вместо окружения»
|
||||||
правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало
|
держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты
|
||||||
бы окружение, — и тогда R1 меняет одну утечку на другую.
|
шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на
|
||||||
|
другую.
|
||||||
|
|
||||||
### R14. В образце секретные поля — пустые строки
|
### CONF-14. В образце секретные поля — пустые строки
|
||||||
|
|
||||||
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
|
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
|
||||||
пример.
|
пример.
|
||||||
|
|
||||||
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее
|
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее
|
||||||
значение: шаблон отрендерился криво, поле осталось от образца, и проверка
|
значение: шаблон отрендерился криво, поле осталось от образца, и проверка
|
||||||
непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг
|
непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг
|
||||||
механически отличимым от заполненного.
|
механически отличимым от заполненного.
|
||||||
|
|
||||||
### R15. Загрузчик проверяет, что обязательные секреты не пусты
|
### CONF-15. Загрузчик проверяет, что обязательные секреты не пусты
|
||||||
|
|
||||||
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
|
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
|
||||||
|
|
||||||
@@ -191,12 +218,12 @@
|
|||||||
в 401 от внешнего API через час работы, — то есть в момент, когда причина
|
в 401 от внешнего API через час работы, — то есть в момент, когда причина
|
||||||
ещё очевидна и связана с деплоем.
|
ещё очевидна и связана с деплоем.
|
||||||
|
|
||||||
### R16. Секреты не попадают в логи
|
### CONF-16. Секреты не попадают в логи
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
|
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
|
||||||
одном уровне.
|
одном уровне.
|
||||||
|
|
||||||
**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они
|
**Почему.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они
|
||||||
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
|
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
|
||||||
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
|
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
|
||||||
записи. Типичный источник утечки — отладочный дамп разобранного конфига при
|
записи. Типичный источник утечки — отладочный дамп разобранного конфига при
|
||||||
@@ -205,7 +232,7 @@
|
|||||||
<!-- local:секретные-поля -->
|
<!-- local:секретные-поля -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
### R17. Конфиг валидируется на старте, до приёма трафика
|
### CONF-17. Конфиг валидируется на старте, до приёма трафика
|
||||||
|
|
||||||
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
|
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
|
||||||
кодом; процесс не стартует «наполовину».
|
кодом; процесс не стартует «наполовину».
|
||||||
@@ -216,27 +243,24 @@
|
|||||||
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
|
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
|
||||||
завершения, и приложение считается развёрнутым.
|
завершения, и приложение считается развёрнутым.
|
||||||
|
|
||||||
### R18. Минимальный набор проверок
|
### CONF-18. Минимальный набор проверок
|
||||||
|
|
||||||
**ДОЛЖЕН.** Валидация покрывает как минимум:
|
**ДОЛЖЕН.** Валидация покрывает как минимум:
|
||||||
|
|
||||||
| № | Что проверяется | Когда всплывёт без проверки |
|
| № | Что проверяется | Когда всплывёт без проверки |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает |
|
| CONF-18.1 | обязательные поля заданы (непустота секретов — CONF-15) | в ветке, которая это поле читает |
|
||||||
| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
|
| CONF-18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
|
||||||
| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
|
| CONF-18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
|
||||||
| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL), реально парсятся | при первом обращении к тому, что за ними стоит |
|
| CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит |
|
||||||
| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
|
| CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
|
||||||
|
|
||||||
**Почему.** Список минимальный и собран по одному признаку — правый
|
**Почему.** Список минимальный и собран по одному признаку — правый
|
||||||
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
|
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
|
||||||
потеряна, и диагностируется как дефект приложения. Проверка на старте
|
потеряна, и диагностируется как дефект приложения. Проверка на старте
|
||||||
сводит их все к одному моменту и одному сообщению.
|
сводит их все к одному моменту и одному сообщению.
|
||||||
|
|
||||||
<!-- local:проверки -->
|
### CONF-19. Проблемы конфига показываются разом
|
||||||
<!-- /local -->
|
|
||||||
|
|
||||||
### R19. Проблемы конфига показываются разом
|
|
||||||
|
|
||||||
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
|
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
|
||||||
списком, а не падает на первой.
|
списком, а не падает на первой.
|
||||||
@@ -247,6 +271,26 @@
|
|||||||
одного источника: разом они читаются как одна причина, по одной — как
|
одного источника: разом они читаются как одна причина, по одной — как
|
||||||
череда несвязанных мелочей.
|
череда несвязанных мелочей.
|
||||||
|
|
||||||
|
### CONF-21. Значение поля в сообщении валидатора — по признаку секретности
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Состав сообщения определяется тем же признаком секретности
|
||||||
|
поля, которым уже пользуются CONF-15 и CONF-16:
|
||||||
|
|
||||||
|
| № | Поле | В сообщении |
|
||||||
|
|---|---|---|
|
||||||
|
| CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» |
|
||||||
|
| CONF-21.2 | секретное | имя поля и суть нарушения, без значения |
|
||||||
|
|
||||||
|
**Почему.** Сообщение без значения отправляет читателя в файл — сличать
|
||||||
|
глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд»
|
||||||
|
или пробел в конце значения из такого сообщения не читаются вовсе. Значение
|
||||||
|
секретного поля при этом печатать некуда: вывод старта уходит в лог
|
||||||
|
супервизора и вывод CI, где круг доступа шире прав файла `0600`, — тот же
|
||||||
|
канал утечки, который закрывает CONF-16. Отдельный список «что не печатать»
|
||||||
|
не заводится: признак один на CONF-15, CONF-16 и CONF-21, а второй список
|
||||||
|
разошёлся бы с первым — и поле оказалось бы секретным для логов, но
|
||||||
|
печатаемым валидатором.
|
||||||
|
|
||||||
## Связано
|
## Связано
|
||||||
|
|
||||||
- `arch/time.md` — формат времени; зона отображения — единственный
|
- `arch/time.md` — формат времени; зона отображения — единственный
|
||||||
@@ -1,49 +1,46 @@
|
|||||||
|
---
|
||||||
|
prefix: KEYS
|
||||||
|
---
|
||||||
|
|
||||||
# Идентификаторы сущностей
|
# Идентификаторы сущностей
|
||||||
|
|
||||||
Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
|
Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
|
||||||
`common/language.md`.
|
`LANGUAGE.md`.
|
||||||
|
|
||||||
## Область действия
|
## Область действия
|
||||||
|
|
||||||
Схема базы меняется тяжело: таблица не переезжает от того, что её
|
Схема базы меняется тяжело: таблица не переезжает от того, что её
|
||||||
потрогали. Поэтому правила распространяются на **новые таблицы**;
|
потрогали. Поэтому правила распространяются на **новые таблицы**;
|
||||||
существующие живут как есть и перечисляются в отступлениях, причём этот
|
существующие живут как есть и перечисляются в отступлениях, причём этот
|
||||||
список постоянный, а не список задач на дочистку.
|
список постоянный, а не список задач на дочистку. Целочисленные ключи
|
||||||
|
существующих приложений — именно такой случай: они не мигрируют, и правила
|
||||||
|
их работы описаны в конвенции схемы, а не здесь.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Вид первичного ключа выбирается один раз на репозиторий
|
### KEYS-1. Первичный ключ новой сущности — ULID
|
||||||
|
|
||||||
**ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех
|
**ДОЛЖЕН.** Новая сущность получает сортируемый строковый идентификатор,
|
||||||
своих таблиц:
|
который порождает приложение, — во **всех** таблицах, включая те, что
|
||||||
|
снаружи не адресуются.
|
||||||
|
|
||||||
> Есть ли в приложении хотя бы одна сущность, которую адресуют **извне** —
|
**Почему.** Ветвления здесь нет намеренно, хотя напрашивается: «эту
|
||||||
> по идентификатору из URL, запроса API или callback-данных?
|
сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности
|
||||||
|
имеют привычку становиться внешними — и тогда целочисленный идентификатор
|
||||||
|
утекает в URL задним числом, а миграция ключа на живых данных стоит
|
||||||
|
несопоставимо дороже, чем взять строковый сразу. Заранее отличить те, с
|
||||||
|
кем это случится, не получается: если бы получалось, они бы уже назывались
|
||||||
|
внешними.
|
||||||
|
|
||||||
| № | Ответ | Вид ключа |
|
Второй довод дешевле, но важнее в быту: одна ментальная модель избавляет от
|
||||||
|---|---|---|
|
спора при заведении каждой таблицы и делает идентификатор **глобальным** —
|
||||||
| R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние |
|
уникальным across таблиц, а не только внутри своей. На этом держится
|
||||||
| R1.2 | ни одной | автоинкремент |
|
корреляция по логам (KEYS-7).
|
||||||
|
|
||||||
**Почему.** Квантор репозиторный, а не потабличный, по двум причинам.
|
Правило про **сгенерированные суррогатные** ключи. Естественные и составные
|
||||||
Внутренние сущности имеют привычку становиться внешними — и тогда
|
ключи у таблиц-деталей (KEYS-6) — третья категория, они допустимы всегда.
|
||||||
целочисленный идентификатор утекает в URL задним числом, а миграция ключа
|
|
||||||
на живых данных стоит несопоставимо дороже, чем взять строковый сразу.
|
|
||||||
Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет
|
|
||||||
от спора при заведении каждой таблицы.
|
|
||||||
|
|
||||||
Критерий — именно **адресация**: снаружи по этому идентификатору
|
### KEYS-2. Идентификатор генерирует приложение, а не база
|
||||||
возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает
|
|
||||||
любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима.
|
|
||||||
|
|
||||||
Запрет смешивания касается двух видов **сгенерированных суррогатных**
|
|
||||||
ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья
|
|
||||||
категория, они допустимы при любом ответе.
|
|
||||||
|
|
||||||
<!-- local:решение -->
|
|
||||||
<!-- /local -->
|
|
||||||
|
|
||||||
### R2. При выборе R1.1 идентификатор генерирует приложение, а не база
|
|
||||||
|
|
||||||
**ДОЛЖЕН.** Значение ключа известно до вставки строки.
|
**ДОЛЖЕН.** Значение ключа известно до вставки строки.
|
||||||
|
|
||||||
@@ -53,26 +50,26 @@
|
|||||||
и достраивать связи вторым проходом, либо иметь два источника истины о
|
и достраивать связи вторым проходом, либо иметь два источника истины о
|
||||||
моменте создания.
|
моменте создания.
|
||||||
|
|
||||||
### R3. Генерация и разбор идентификаторов — в единственной точке
|
### KEYS-3. Генерация и разбор идентификаторов — в единственной точке
|
||||||
|
|
||||||
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
|
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
|
||||||
Самодельных генераторов и парсеров в коде нет.
|
Самодельных генераторов и парсеров в коде нет.
|
||||||
|
|
||||||
**Почему.** Нормализация регистра (R4) и проверка формата обязаны
|
**Почему.** Нормализация регистра (KEYS-4) и проверка формата обязаны
|
||||||
применяться ко всем идентификаторам без исключения. Любая вторая точка
|
применяться ко всем идентификаторам без исключения. Любая вторая точка
|
||||||
входа рано или поздно окажется той, где нормализацию забыли, — и дефект
|
входа рано или поздно окажется той, где нормализацию забыли, — и дефект
|
||||||
проявится не там, где создан.
|
проявится не там, где создан.
|
||||||
|
|
||||||
### R4. Канонический вид — нижний регистр
|
### KEYS-4. Канонический вид — нижний регистр
|
||||||
|
|
||||||
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
|
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
|
||||||
|
|
||||||
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
|
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
|
||||||
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
|
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
|
||||||
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3)
|
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3)
|
||||||
разный регистр появится в базе сам собой.
|
разный регистр появится в базе сам собой.
|
||||||
|
|
||||||
### R5. Внешний идентификатор разбирается до обращения к базе
|
### KEYS-5. Внешний идентификатор разбирается до обращения к базе
|
||||||
|
|
||||||
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
|
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
|
||||||
раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от
|
раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от
|
||||||
@@ -80,20 +77,28 @@
|
|||||||
|
|
||||||
| № | Откуда пришёл | Разбор не удался → |
|
| № | Откуда пришёл | Разбор не удался → |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
|
| KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
|
||||||
| R5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
|
| KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
|
||||||
|
|
||||||
**Почему.** Синтаксически невалидное значение не может соответствовать
|
**Почему.** Синтаксически невалидное значение не может соответствовать
|
||||||
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
|
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
|
||||||
границе, мы дёшево снимаем целый класс мусорного трафика.
|
границе, мы дёшево снимаем целый класс мусорного трафика.
|
||||||
|
|
||||||
Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в
|
Разделение KEYS-5.1 и KEYS-5.2 нужно, потому что источники значат разное.
|
||||||
URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию
|
Мусор в URL — это чужая или протухшая ссылка, и «не найдено» описывает
|
||||||
точно. Мусор из собственной формы — это баг интерфейса или устаревший
|
ситуацию точно. Мусор из собственной формы — это баг интерфейса или
|
||||||
экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики
|
устаревший экран; ответ «не найдено» здесь скрывает дефект и лишает
|
||||||
единственный момент, когда он заметен.
|
диагностики единственный момент, когда он заметен.
|
||||||
|
|
||||||
### R6. У таблиц-деталей допустим естественный или составной ключ
|
Таблица перечисляет **внешние** источники — те, откуда значение приходит
|
||||||
|
вместе с запросом, и правило говорит, что отдать в ответ. Идентификатор из
|
||||||
|
конфигурации, из собственной базы или из фикстуры сюда не относится: он
|
||||||
|
ничего не отдаёт наружу, а его невалидность означает, что сломано у нас.
|
||||||
|
Формат идентификатора в конфигурации проверяется на старте
|
||||||
|
(`CONF-18`), невалидное значение в собственной базе — нарушенный
|
||||||
|
инвариант единой точки (KEYS-3).
|
||||||
|
|
||||||
|
### KEYS-6. У таблиц-деталей допустим естественный или составной ключ
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
|
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
|
||||||
сгенерированный идентификатор не заводится.
|
сгенерированный идентификатор не заводится.
|
||||||
@@ -103,10 +108,10 @@ URL — это чужая или протухшая ссылка, и «не на
|
|||||||
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
|
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
|
||||||
информации он не несёт.
|
информации он не несёт.
|
||||||
|
|
||||||
### R7. Прочие генерируемые идентификаторы — через ту же точку
|
### KEYS-7. Прочие генерируемые идентификаторы — через ту же точку
|
||||||
|
|
||||||
**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч,
|
**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч,
|
||||||
задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же
|
задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же
|
||||||
формате.
|
формате.
|
||||||
|
|
||||||
**Почему.** Единый формат делает работающим главный побочный эффект
|
**Почему.** Единый формат делает работающим главный побочный эффект
|
||||||
@@ -117,7 +122,7 @@ URL — это чужая или протухшая ссылка, и «не на
|
|||||||
|
|
||||||
## Почему ULID, а не UUID
|
## Почему ULID, а не UUID
|
||||||
|
|
||||||
Ветка R1.1 требует **сортируемый** строковый идентификатор. UUIDv4 не
|
KEYS-1 требует **сортируемый** строковый идентификатор. UUIDv4 не
|
||||||
сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него
|
сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него
|
||||||
остаются два довода: 36 символов против 26 и дефисы, из-за которых
|
остаются два довода: 36 символов против 26 и дефисы, из-за которых
|
||||||
идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово.
|
идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово.
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
---
|
||||||
|
prefix: TIME
|
||||||
|
---
|
||||||
|
|
||||||
# Время
|
# Время
|
||||||
|
|
||||||
Как приложение записывает моменты и длительности: в каком формате, откуда
|
Как приложение записывает моменты и длительности: в каком формате, откуда
|
||||||
берётся значение и где появляется не-UTC. Форма записи —
|
берётся значение и где появляется не-UTC. Форма записи —
|
||||||
`common/language.md`.
|
`LANGUAGE.md`.
|
||||||
|
|
||||||
## Область действия
|
## Область действия
|
||||||
|
|
||||||
@@ -14,7 +18,7 @@
|
|||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Единый формат — RFC 3339, UTC, суффикс `Z`
|
### TIME-1. Единый формат — RFC 3339, UTC, суффикс `Z`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` —
|
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` —
|
||||||
одинаково в хранении, логах, API и обмене с внешними системами.
|
одинаково в хранении, логах, API и обмене с внешними системами.
|
||||||
@@ -25,7 +29,7 @@
|
|||||||
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
|
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
|
||||||
убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
|
убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
|
||||||
|
|
||||||
### R2. Ширина строки фиксируется на каждый носитель
|
### TIME-2. Ширина строки фиксируется на каждый носитель
|
||||||
|
|
||||||
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
|
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
|
||||||
строки времени одна и от записи к записи не плавает.
|
строки времени одна и от записи к записи не плавает.
|
||||||
@@ -38,18 +42,18 @@
|
|||||||
везде, а только на тех парах записей, где дробная часть оказалась короче, —
|
везде, а только на тех парах записей, где дробная часть оказалась короче, —
|
||||||
то есть редко, выборочно и невоспроизводимо.
|
то есть редко, выборочно и невоспроизводимо.
|
||||||
|
|
||||||
### R3. Точность разных носителей может различаться
|
### TIME-3. Точность разных носителей может различаться
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
|
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
|
||||||
|
|
||||||
**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что
|
**Почему.** Квантор в TIME-2 — на носитель, а не на приложение, потому что
|
||||||
строки разных носителей между собой не сравниваются: сортировка идёт внутри
|
строки разных носителей между собой не сравниваются: сортировка идёт внутри
|
||||||
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался
|
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался
|
||||||
как «одна точность на всё приложение»: от подгонки формата логов под формат
|
как «одна точность на всё приложение»: от подгонки формата логов под формат
|
||||||
колонки ни одна пара строк не становится сравнимой, зато точность режется до
|
колонки ни одна пара строк не становится сравнимой, зато точность режется до
|
||||||
худшего из носителей.
|
худшего из носителей.
|
||||||
|
|
||||||
### R4. Локальное время не хранится и не передаётся
|
### TIME-4. Локальное время не хранится и не передаётся
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
|
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
|
||||||
зоне.
|
зоне.
|
||||||
@@ -60,29 +64,45 @@
|
|||||||
разберёт час перехода на зимнее время: этот час идёт дважды, две записи
|
разберёт час перехода на зимнее время: этот час идёт дважды, две записи
|
||||||
получают одинаковую метку, и порядок между ними не восстанавливается ничем.
|
получают одинаковую метку, и порядок между ними не восстанавливается ничем.
|
||||||
|
|
||||||
### R5. Единая точка получения «сейчас», форматирования и разбора
|
### TIME-13. Чужой вход нормализуется при разборе, а не отклоняется
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Валидное по RFC 3339 значение с офсетом, отличным от `Z`, или с
|
||||||
|
долями секунды принимается от внешней системы и приводится к каноническому
|
||||||
|
виду (TIME-1) в точке разбора (TIME-5).
|
||||||
|
|
||||||
|
**Почему.** Канонический вид — обязательство нашего писателя, а не
|
||||||
|
контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же
|
||||||
|
момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со
|
||||||
|
стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение
|
||||||
|
нарушает форму и ширину носителя (TIME-1, TIME-2) и портит сортировку
|
||||||
|
выборочно — только на записях, пришедших извне, и далеко от места разбора.
|
||||||
|
Нормализация
|
||||||
|
в единой точке разбора оставляет ровно одно место, где неканонический вид
|
||||||
|
существует, — по ту сторону границы его уже нет.
|
||||||
|
|
||||||
|
### TIME-5. Единая точка получения «сейчас», форматирования и разбора
|
||||||
|
|
||||||
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
|
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
|
||||||
метки; прямые вызовы часов по коду не разбросаны.
|
метки; прямые вызовы часов по коду не разбросаны.
|
||||||
|
|
||||||
**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех
|
**Почему.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех
|
||||||
меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
|
меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
|
||||||
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
|
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
|
||||||
данных, и обнаруживается, когда испорченных записей уже накопилось.
|
данных, и обнаруживается, когда испорченных записей уже накопилось.
|
||||||
Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`).
|
Соображение то же, что для идентификаторов (`arch/db-identifiers.md TIME-3`).
|
||||||
|
|
||||||
### R6. Дефолтов времени в схеме БД нет
|
### TIME-6. Дефолтов времени в схеме БД нет
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
|
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
|
||||||
|
|
||||||
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий
|
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий
|
||||||
код: значение появляется, но приходит от сервера БД — то есть с других часов
|
код: значение появляется, но приходит от сервера БД — то есть с других часов
|
||||||
и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка
|
и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка
|
||||||
падает громко и чинится в момент написания, а не при разборе расхождения
|
падает громко и чинится в момент написания, а не при разборе расхождения
|
||||||
между временем в записи и временем в логе. Правило то же, что для
|
между временем в записи и временем в логе. Правило то же, что для
|
||||||
идентификаторов (`arch/db-identifiers.md R2`).
|
идентификаторов (`arch/db-identifiers.md TIME-2`).
|
||||||
|
|
||||||
### R7. Длительность — отдельная величина, а не пара меток
|
### TIME-7. Длительность — отдельная величина, а не пара меток
|
||||||
|
|
||||||
**ДОЛЖЕН.** Длительность операции записывается числом (обычно
|
**ДОЛЖЕН.** Длительность операции записывается числом (обычно
|
||||||
миллисекундами) в поле вида `duration_ms`.
|
миллисекундами) в поле вида `duration_ms`.
|
||||||
@@ -92,9 +112,9 @@
|
|||||||
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
|
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
|
||||||
логе; число сравнивается, агрегируется и попадает в перцентили без этого
|
логе; число сравнивается, агрегируется и попадает в перцентили без этого
|
||||||
шага. Кроме того, разность сохранённых меток считается по стенным часам и
|
шага. Кроме того, разность сохранённых меток считается по стенным часам и
|
||||||
наследует их дефект (R9).
|
наследует их дефект (TIME-9).
|
||||||
|
|
||||||
### R8. Длительность засекает слой, который делает вызов
|
### TIME-8. Длительность засекает слой, который делает вызов
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
|
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
|
||||||
|
|
||||||
@@ -103,14 +123,14 @@
|
|||||||
вызова. В обоих случаях число остаётся правдоподобным и потому не
|
вызова. В обоих случаях число остаётся правдоподобным и потому не
|
||||||
оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
|
оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
|
||||||
|
|
||||||
### R9. Момент и интервал берутся с разных часов
|
### TIME-9. Момент и интервал берутся с разных часов
|
||||||
|
|
||||||
**ДОЛЖЕН.** Источник зависит от того, что записывается:
|
**ДОЛЖЕН.** Источник зависит от того, что записывается:
|
||||||
|
|
||||||
| № | Величина | Источник |
|
| № | Величина | Источник |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R9.1 | момент события | стенные часы через единую точку (R5) |
|
| TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) |
|
||||||
| R9.2 | длительность операции | монотонные часы процесса |
|
| TIME-9.2 | длительность операции | монотонные часы процесса |
|
||||||
|
|
||||||
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
|
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
|
||||||
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
|
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
|
||||||
@@ -120,7 +140,7 @@
|
|||||||
упустить: источник меток времени и источник интервалов — разные, даже если
|
упустить: источник меток времени и источник интервалов — разные, даже если
|
||||||
оба называются «часы».
|
оба называются «часы».
|
||||||
|
|
||||||
### R10. Не-UTC существует только на слое отображения
|
### TIME-10. Не-UTC существует только на слое отображения
|
||||||
|
|
||||||
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
|
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
|
||||||
проникает в хранение, сортировку и логи.
|
проникает в хранение, сортировку и логи.
|
||||||
@@ -132,7 +152,7 @@
|
|||||||
смещение удваивается, результат остаётся похожим на правду, а найти
|
смещение удваивается, результат остаётся похожим на правду, а найти
|
||||||
виновный слой можно только перечитав их все.
|
виновный слой можно только перечитав их все.
|
||||||
|
|
||||||
### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
|
### TIME-11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение
|
**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение
|
||||||
по умолчанию — `UTC`.
|
по умолчанию — `UTC`.
|
||||||
@@ -143,7 +163,7 @@
|
|||||||
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
|
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
|
||||||
как «зону не задали», а не как «где-то потерялось смещение».
|
как «зону не задали», а не как «где-то потерялось смещение».
|
||||||
|
|
||||||
### R12. В календарных вычислениях зона указывается явно
|
### TIME-12. В календарных вычислениях зона указывается явно
|
||||||
|
|
||||||
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
|
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
|
||||||
явно переданной зоной, а не с системной зоной процесса.
|
явно переданной зоной, а не с системной зоной процесса.
|
||||||
@@ -153,7 +173,7 @@
|
|||||||
расхождение не воспроизводится там, где его заметили, и объясняется средой,
|
расхождение не воспроизводится там, где его заметили, и объясняется средой,
|
||||||
а не кодом. Явно переданная зона делает результат функцией от аргументов.
|
а не кодом. Явно переданная зона делает результат функцией от аргументов.
|
||||||
|
|
||||||
Зона по умолчанию здесь та же, что и для отображения (R11); календарная
|
Зона по умолчанию здесь та же, что и для отображения (TIME-11); календарная
|
||||||
логика, которой нужна другая, получает её тем же явным аргументом.
|
логика, которой нужна другая, получает её тем же явным аргументом.
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
|
prefix: GCFG
|
||||||
extends: arch/config.md
|
extends: arch/config.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# Конфигурация: реализация на Go
|
# Конфигурация: реализация на Go
|
||||||
|
|
||||||
Как `arch/config.md` выглядит в Go-приложении: формат, загрузчик, границы
|
Как `arch/config.md` выглядит в Go-приложении: формат, загрузчик, границы
|
||||||
запрета на окружение. Форма записи — `common/language.md`.
|
запрета на окружение. Форма записи — `LANGUAGE.md`.
|
||||||
|
|
||||||
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
|
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
|
||||||
проверка их непустоты идёт вместе с остальной валидацией — как описано в
|
проверка их непустоты идёт вместе с остальной валидацией — как описано в
|
||||||
@@ -13,7 +14,7 @@ extends: arch/config.md
|
|||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Формат конфигурации — TOML
|
### GCFG-1. Формат конфигурации — TOML
|
||||||
|
|
||||||
**ДОЛЖЕН.** Конфиг — файл TOML.
|
**ДОЛЖЕН.** Конфиг — файл TOML.
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ extends: arch/config.md
|
|||||||
поправленный руками на сервере, ломается заметно, а не меняет вложенность
|
поправленный руками на сервере, ломается заметно, а не меняет вложенность
|
||||||
молча.
|
молча.
|
||||||
|
|
||||||
### R2. Разбор и валидация — целиком в `internal/config`
|
### GCFG-2. Разбор и валидация — целиком в `internal/config`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
|
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
|
||||||
`internal/config`; наружу пакет отдаёт готовую структуру `Config`.
|
`internal/config`; наружу пакет отдаёт готовую структуру `Config`.
|
||||||
@@ -34,11 +35,11 @@ extends: arch/config.md
|
|||||||
после — уже нет, и это единственная граница, на которой такое утверждение
|
после — уже нет, и это единственная граница, на которой такое утверждение
|
||||||
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
|
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
|
||||||
«проверено ли это поле» только чтением всех вызывающих, часть полей
|
«проверено ли это поле» только чтением всех вызывающих, часть полей
|
||||||
неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ
|
неизбежно окажется непроверенной, и fail-fast (GCFG-15) выродится в отказ
|
||||||
посреди работы. Экспортированный разбор вдобавок даёт второй способ
|
посреди работы. Экспортированный разбор вдобавок даёт второй способ
|
||||||
получить конфиг — мимо умолчаний (R5).
|
получить конфиг — мимо умолчаний (GCFG-5).
|
||||||
|
|
||||||
### R3. Весь конфиг — одна корневая структура
|
### GCFG-3. Весь конфиг — одна корневая структура
|
||||||
|
|
||||||
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
|
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
|
||||||
под-структур по секциям.
|
под-структур по секциям.
|
||||||
@@ -50,7 +51,7 @@ extends: arch/config.md
|
|||||||
(включена интеграция — заданы все её поля) при этом перестают быть
|
(включена интеграция — заданы все её поля) при этом перестают быть
|
||||||
проверяемыми в одном месте.
|
проверяемыми в одном месте.
|
||||||
|
|
||||||
### R4. Под-структуры названы по секциям файла
|
### GCFG-4. Под-структуры названы по секциям файла
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
|
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ extends: arch/config.md
|
|||||||
восстанавливается чтением тегов, и проделывать это приходится для каждой
|
восстанавливается чтением тегов, и проделывать это приходится для каждой
|
||||||
секции заново.
|
секции заново.
|
||||||
|
|
||||||
### R5. Умолчания задаёт `Default()`
|
### GCFG-5. Умолчания задаёт `Default()`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
|
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
|
||||||
накладывается поверх.
|
накладывается поверх.
|
||||||
@@ -72,7 +73,7 @@ extends: arch/config.md
|
|||||||
подставляют разное. `Default()` — единственное место, откуда список
|
подставляют разное. `Default()` — единственное место, откуда список
|
||||||
умолчаний читается разом и переносится в образец.
|
умолчаний читается разом и переносится в образец.
|
||||||
|
|
||||||
### R6. Имя файла фиксировано, путь переопределяется флагом
|
### GCFG-6. Имя файла фиксировано, путь переопределяется флагом
|
||||||
|
|
||||||
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
|
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
|
||||||
путь переопределяет флаг `--config=path`, образец рядом —
|
путь переопределяет флаг `--config=path`, образец рядом —
|
||||||
@@ -85,7 +86,7 @@ extends: arch/config.md
|
|||||||
`config.example.toml` вдобавок делает расхождение образца с реальным
|
`config.example.toml` вдобавок делает расхождение образца с реальным
|
||||||
конфигом видимым обычным `diff`, а не вычиткой.
|
конфигом видимым обычным `diff`, а не вычиткой.
|
||||||
|
|
||||||
### R7. Длительности — собственный тип с `UnmarshalText`
|
### GCFG-7. Длительности — собственный тип с `UnmarshalText`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
|
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
|
||||||
`time.Duration`:
|
`time.Duration`:
|
||||||
@@ -105,11 +106,11 @@ func (d Duration) Std() time.Duration { … }
|
|||||||
в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
|
в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
|
||||||
|
|
||||||
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть
|
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть
|
||||||
раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности
|
раньше, чем начинает работать сбор проблем (GCFG-12). Ошибка в длительности
|
||||||
приходит отдельно и первой, а остальные проблемы конфига в этом запуске не
|
приходит отдельно и первой, а остальные проблемы конфига в этом запуске не
|
||||||
показываются.
|
показываются.
|
||||||
|
|
||||||
### R8. Приложение не читает окружение
|
### GCFG-8. Приложение не читает окружение
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
|
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
|
||||||
|
|
||||||
@@ -120,9 +121,9 @@ func (d Duration) Std() time.Duration { … }
|
|||||||
чтением всего кода — а узнают о нём обычно на сервере, где переменная не
|
чтением всего кода — а узнают о нём обычно на сервере, где переменная не
|
||||||
выставлена.
|
выставлена.
|
||||||
|
|
||||||
### R9. Проверка запрета покрывает все входы в окружение
|
### GCFG-9. Проверка запрета покрывает всю семью `os`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только
|
**ДОЛЖЕН.** Механическая проверка GCFG-8 (`forbidigo`) ловит не только
|
||||||
`os.Getenv`:
|
`os.Getenv`:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -134,17 +135,23 @@ func (d Duration) Std() time.Duration { … }
|
|||||||
незаметно: правило числится механизированным, и глазами его больше никто не
|
незаметно: правило числится механизированным, и глазами его больше никто не
|
||||||
проверяет.
|
проверяет.
|
||||||
|
|
||||||
### R10. За границей приложения запрет не действует
|
Полного покрытия этот паттерн не даёт и дать не может: мимо него проходят
|
||||||
|
`syscall.Getenv`, вызов через алиас пакета и чтение `/proc/self/environ`.
|
||||||
|
Проверка закрывает обычные способы — те, которыми окружение читают не
|
||||||
|
нарочно; сознательный обход она не ловит, и считать GCFG-8 полностью
|
||||||
|
механизированным нельзя.
|
||||||
|
|
||||||
|
### GCFG-10. За границей приложения запрет не действует
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
|
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
|
||||||
приложение:
|
приложение:
|
||||||
|
|
||||||
| № | Кто читает | Вердикт |
|
| № | Кто читает | Вердикт |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
|
| GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
|
||||||
| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
|
| GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
|
||||||
|
|
||||||
**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не
|
**Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
|
||||||
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
|
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
|
||||||
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
|
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
|
||||||
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
|
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
|
||||||
@@ -152,19 +159,19 @@ func (d Duration) Std() time.Duration { … }
|
|||||||
лечится `//nolint` наугад: там, где легальные случаи приходится глушить
|
лечится `//nolint` наугад: там, где легальные случаи приходится глушить
|
||||||
руками, вместе с ними проходят и нелегальные.
|
руками, вместе с ними проходят и нелегальные.
|
||||||
|
|
||||||
### R11. Прокси задаётся конфигом, а не `HTTP_PROXY`
|
### GCFG-11. Прокси задаётся конфигом, а не `HTTP_PROXY`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
|
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
|
||||||
|
|
||||||
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
|
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
|
||||||
дефолтный `http.Transport` — но читает он их от имени приложения и меняет
|
дефолтный `http.Transport` — но читает он их от имени приложения и меняет
|
||||||
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
|
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
|
||||||
тот второй канал, который запрещает R8, и притом самый неудобный: маршрут
|
тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут
|
||||||
исходящих запросов отличается от машины к машине без единого следа в
|
исходящих запросов отличается от машины к машине без единого следа в
|
||||||
конфиге и в образце, а расследование начинается с вопроса «почему на
|
конфиге и в образце, а расследование начинается с вопроса «почему на
|
||||||
сервере ходит не так, как локально».
|
сервере ходит не так, как локально».
|
||||||
|
|
||||||
### R12. Проблемы конфига собираются `errors.Join`
|
### GCFG-12. Проблемы конфига собираются `errors.Join`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
|
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
|
||||||
ошибка, собранная `errors.Join`.
|
ошибка, собранная `errors.Join`.
|
||||||
@@ -175,7 +182,7 @@ func (d Duration) Std() time.Duration { … }
|
|||||||
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
|
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
|
||||||
вложенной проблеме.
|
вложенной проблеме.
|
||||||
|
|
||||||
### R13. Имя зоны проверяется `time.LoadLocation`
|
### GCFG-13. Имя зоны проверяется `time.LoadLocation`
|
||||||
|
|
||||||
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
|
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
|
||||||
|
|
||||||
@@ -183,9 +190,9 @@ func (d Duration) Std() time.Duration { … }
|
|||||||
тогда, когда база зон его знает, и никакая проверка формата не отличит
|
тогда, когда база зон его знает, и никакая проверка формата не отличит
|
||||||
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
|
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
|
||||||
доживает до первого форматирования времени — то есть до рантайма, мимо
|
доживает до первого форматирования времени — то есть до рантайма, мимо
|
||||||
fail-fast (R15).
|
fail-fast (GCFG-15).
|
||||||
|
|
||||||
### R14. `time/tzdata` импортируется в `main`
|
### GCFG-14. `time/tzdata` импортируется в `main`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
|
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
|
||||||
пакете.
|
пакете.
|
||||||
@@ -193,11 +200,11 @@ fail-fast (R15).
|
|||||||
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
|
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
|
||||||
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
|
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
|
||||||
полагаться на системную» принадлежит собираемой программе. Со встроенной
|
полагаться на системную» принадлежит собираемой программе. Со встроенной
|
||||||
базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без
|
базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без
|
||||||
неё тот же конфиг валиден на машине разработчика и падает в контейнере без
|
неё тот же конфиг валиден на машине разработчика и падает в контейнере без
|
||||||
zoneinfo, а сообщение указывает не на ту причину.
|
zoneinfo, а сообщение указывает не на ту причину.
|
||||||
|
|
||||||
### R15. Невалидный конфиг — `ERROR` и выход из `main`
|
### GCFG-15. Невалидный конфиг — `ERROR` и выход из `main`
|
||||||
|
|
||||||
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
|
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
|
||||||
старта серверов и воркеров.
|
старта серверов и воркеров.
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
---
|
---
|
||||||
|
prefix: GKEY
|
||||||
extends: arch/db-identifiers.md
|
extends: arch/db-identifiers.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# Идентификаторы: реализация на Go
|
# Идентификаторы: реализация на Go
|
||||||
|
|
||||||
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID
|
Как `arch/db-identifiers.md` выглядит в Go-приложении. Форма записи —
|
||||||
(ветка `arch/db-identifiers.md` R1.1). Форма записи — `common/language.md`.
|
`LANGUAGE.md`.
|
||||||
|
|
||||||
Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он
|
Единая точка из `KEYS-3` — пакет `internal/ident`: он
|
||||||
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
|
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
|
||||||
(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся.
|
(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Генерация и разбор — только через `internal/ident`
|
### GKEY-1. Генерация и разбор — только через `internal/ident`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
|
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
|
||||||
`internal/ident`; других генераторов и парсеров id в коде нет.
|
`internal/ident`; других генераторов и парсеров id в коде нет.
|
||||||
|
|
||||||
**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов
|
**Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов
|
||||||
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
|
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
|
||||||
выглядит нарушением: значение получается валидное, просто мимо нормализации
|
выглядит нарушением: значение получается валидное, просто мимо нормализации
|
||||||
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
|
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
|
||||||
@@ -26,12 +27,12 @@ ULID-библиотеки — одна строка, доступная из л
|
|||||||
модуля, а «забытая нормализация» не находится ничем, пока запрос молча не
|
модуля, а «забытая нормализация» не находится ничем, пока запрос молча не
|
||||||
перестанет находить существующую запись.
|
перестанет находить существующую запись.
|
||||||
|
|
||||||
### R2. Первичный ключ генерируется в `Create`-методах store
|
### GKEY-2. Первичный ключ генерируется в `Create`-методах store
|
||||||
|
|
||||||
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
|
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
|
||||||
внутри `Create`-метода слоя store.
|
внутри `Create`-метода слоя store.
|
||||||
|
|
||||||
**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было
|
**Почему.** `KEYS-2` требует, чтобы значение было
|
||||||
известно до вставки, но не говорит, кто его присваивает. Store — последний
|
известно до вставки, но не говорит, кто его присваивает. Store — последний
|
||||||
слой, через который проходят все пути создания строки, включая импорт,
|
слой, через который проходят все пути создания строки, включая импорт,
|
||||||
фоновые задания и тесты. Генерация выше по стеку делает присвоение
|
фоновые задания и тесты. Генерация выше по стеку делает присвоение
|
||||||
@@ -39,18 +40,18 @@ ULID-библиотеки — одна строка, доступная из л
|
|||||||
строку в колонку ключа: для строкового PK это валидное значение, база его
|
строку в колонку ключа: для строкового PK это валидное значение, база его
|
||||||
не отклонит, и дефект обнаружится на второй такой вставке.
|
не отклонит, и дефект обнаружится на второй такой вставке.
|
||||||
|
|
||||||
### R3. Прочие идентификаторы генерируются в точке начала операции
|
### GKEY-3. Прочие идентификаторы генерируются в точке начала операции
|
||||||
|
|
||||||
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
|
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
|
||||||
вызовом `ident.NewID()` там, где операция начинается.
|
вызовом `ident.NewID()` там, где операция начинается.
|
||||||
|
|
||||||
**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) —
|
**Почему.** Смысл такого идентификатора (`KEYS-7`) —
|
||||||
сшивать записи лога всей операции. Созданный ниже по стеку или в момент
|
сшивать записи лога всей операции. Созданный ниже по стеку или в момент
|
||||||
первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
|
первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
|
||||||
когда операция упала до того, как что-либо записала: без общего ключа эти
|
когда операция упала до того, как что-либо записала: без общего ключа эти
|
||||||
записи из лога не собираются вообще.
|
записи из лога не собираются вообще.
|
||||||
|
|
||||||
### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
|
### GKEY-4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
|
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
|
||||||
Go-миграции, порождаются с историческим временем строки, а не с текущим.
|
Go-миграции, порождаются с историческим временем строки, а не с текущим.
|
||||||
@@ -62,18 +63,18 @@ Go-миграции, порождаются с историческим врем
|
|||||||
Исправить это потом нельзя: исходное время в идентификаторе не
|
Исправить это потом нельзя: исходное время в идентификаторе не
|
||||||
восстановить.
|
восстановить.
|
||||||
|
|
||||||
### R5. Разбор — на входных границах, до обращения к store
|
### GKEY-5. Разбор — на входных границах, до обращения к store
|
||||||
|
|
||||||
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
|
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
|
||||||
callback'а бота — раньше, чем идентификатор попадёт в store.
|
callback'а бота — раньше, чем идентификатор попадёт в store.
|
||||||
|
|
||||||
**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана
|
**Почему.** Реализация `KEYS-5`. Граница выбрана
|
||||||
транспортная, потому что только на ней известен источник значения, от
|
транспортная, потому что только на ней известен источник значения, от
|
||||||
которого зависит реакция (R8): store видит одинаковую строку независимо от
|
которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от
|
||||||
того, пришла она из URL или из собственной формы, и ответить по-разному
|
того, пришла она из URL или из собственной формы, и ответить по-разному
|
||||||
оттуда уже невозможно.
|
оттуда уже невозможно.
|
||||||
|
|
||||||
### R6. Id в структурах — обычный `string`
|
### GKEY-6. Id в структурах — обычный `string`
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
|
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
|
||||||
`string`.
|
`string`.
|
||||||
@@ -84,35 +85,35 @@ callback'а бота — раньше, чем идентификатор поп
|
|||||||
параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
|
параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
|
||||||
json и шаблонами, то есть даёт цену без выгоды.
|
json и шаблонами, то есть даёт цену без выгоды.
|
||||||
|
|
||||||
### R7. Отдельный тип — когда появляется вторая семья идентификаторов
|
### GKEY-7. Отдельный тип — когда появляется вторая семья идентификаторов
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
|
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
|
||||||
можно перепутать, для них заводятся различимые типы.
|
можно перепутать, для них заводятся различимые типы.
|
||||||
|
|
||||||
**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на
|
**Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
|
||||||
типизацию навсегда. Условие названо ровно то, при котором тип начинает
|
типизацию навсегда. Условие названо ровно то, при котором тип начинает
|
||||||
работать: пока все идентификаторы — `string`, подстановка одного вида
|
работать: пока все идентификаторы — `string`, подстановка одного вида
|
||||||
вместо другого компилируется и обнаруживается только на данных.
|
вместо другого компилируется и обнаруживается только на данных.
|
||||||
|
|
||||||
### R8. Реакция на невалидный id зависит от источника
|
### GKEY-8. Реакция на невалидный id зависит от источника
|
||||||
|
|
||||||
**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло
|
**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло
|
||||||
значение:
|
значение:
|
||||||
|
|
||||||
| № | Источник | Ответ |
|
| № | Источник | Ответ |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R8.1 | путь или query URL | 404 без обращения к store |
|
| GKEY-8.1 | путь или query URL | 404 без обращения к store |
|
||||||
| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
|
| GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
|
||||||
|
|
||||||
**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах
|
**Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
|
||||||
HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи —
|
HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи —
|
||||||
и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2
|
и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2
|
||||||
значение сформировало само приложение, и невалидность означает баг
|
значение сформировало само приложение, и невалидность означает баг
|
||||||
интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно,
|
интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно,
|
||||||
в логах не оставляет аномалии и тем самым съедает единственный момент,
|
в логах не оставляет аномалии и тем самым съедает единственный момент,
|
||||||
когда дефект заметен.
|
когда дефект заметен.
|
||||||
|
|
||||||
### R9. Транспорт не создаёт доменные ошибки
|
### GKEY-9. Транспорт не создаёт доменные ошибки
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
|
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
|
||||||
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
|
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
---
|
||||||
|
prefix: MIGR
|
||||||
|
---
|
||||||
|
|
||||||
# Схема и миграции (SQLite, Go)
|
# Схема и миграции (SQLite, Go)
|
||||||
|
|
||||||
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
|
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
|
||||||
Go-приложении. Форма записи — `common/language.md`.
|
Go-приложении. Форма записи — `LANGUAGE.md`.
|
||||||
|
|
||||||
## Область действия
|
## Область действия
|
||||||
|
|
||||||
@@ -12,7 +16,7 @@ Go-приложении. Форма записи — `common/language.md`.
|
|||||||
|
|
||||||
## Миграции
|
## Миграции
|
||||||
|
|
||||||
### R1. Миграции ведёт goose
|
### MIGR-1. Миграции ведёт goose
|
||||||
|
|
||||||
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
|
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
|
||||||
goose.
|
goose.
|
||||||
@@ -24,7 +28,7 @@ goose.
|
|||||||
существующую таблицу. На сервере это означает ручной разбор состояния
|
существующую таблицу. На сервере это означает ручной разбор состояния
|
||||||
схемы вместо автоматического деплоя.
|
схемы вместо автоматического деплоя.
|
||||||
|
|
||||||
### R2. Файлы миграций лежат рядом со store-слоем
|
### MIGR-2. Файлы миграций лежат рядом со store-слоем
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
|
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
|
||||||
схемой.
|
схемой.
|
||||||
@@ -35,14 +39,14 @@ goose.
|
|||||||
код без миграции, либо миграция без кода; расходятся они на сервере, где
|
код без миграции, либо миграция без кода; расходятся они на сервере, где
|
||||||
схема ещё старая.
|
схема ещё старая.
|
||||||
|
|
||||||
### R3. Форма миграции выбирается по тому, нужен ли код
|
### MIGR-3. Форма миграции выбирается по тому, нужен ли код
|
||||||
|
|
||||||
**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое:
|
**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое:
|
||||||
|
|
||||||
| № | Что делает миграция | Форма |
|
| № | Что делает миграция | Форма |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
|
| MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
|
||||||
| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
|
| MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
|
||||||
|
|
||||||
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
|
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
|
||||||
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
|
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
|
||||||
@@ -50,12 +54,12 @@ goose.
|
|||||||
|
|
||||||
Обратное направление дороже. Перенос данных и генерация идентификаторов
|
Обратное направление дороже. Перенос данных и генерация идентификаторов
|
||||||
выражаются на SQL либо громоздко, либо неточно: идентификатор по
|
выражаются на SQL либо громоздко, либо неточно: идентификатор по
|
||||||
`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена
|
`KEYS-2` порождает приложение, и SQL-миграция вынуждена
|
||||||
завести для него второй генератор — ровно то, что запрещает
|
завести для него второй генератор — ровно то, что запрещает
|
||||||
`arch/db-identifiers.md` R3. Единообразие формы здесь покупается
|
`KEYS-3`. Единообразие формы здесь покупается
|
||||||
дублированием логики, которая уже есть в коде.
|
дублированием логики, которая уже есть в коде.
|
||||||
|
|
||||||
### R4. В деплое схема движется только вперёд
|
### MIGR-4. В деплое схема движется только вперёд
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
|
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
|
||||||
ошибка исправляется новой миграцией вперёд.
|
ошибка исправляется новой миграцией вперёд.
|
||||||
@@ -67,14 +71,14 @@ goose.
|
|||||||
следующей миграцией, оставляет целыми и данные, и журнал применённых
|
следующей миграцией, оставляет целыми и данные, и журнал применённых
|
||||||
версий.
|
версий.
|
||||||
|
|
||||||
### R5. Down пишется, когда он честно обращает up
|
### MIGR-5. Down пишется, когда он честно обращает up
|
||||||
|
|
||||||
**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up:
|
**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up:
|
||||||
|
|
||||||
| № | Что делает up | Down |
|
| № | Что делает up | Down |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
|
| MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
|
||||||
| R5.2 | необратимо преобразует данные | не пишется |
|
| MIGR-5.2 | необратимо преобразует данные | не пишется |
|
||||||
|
|
||||||
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда,
|
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда,
|
||||||
и именно там он обязан действительно обращать up. Имитация опаснее
|
и именно там он обязан действительно обращать up. Имитация опаснее
|
||||||
@@ -83,7 +87,7 @@ goose.
|
|||||||
down останавливает сразу и заставляет пересоздать базу — это дешевле, чем
|
down останавливает сразу и заставляет пересоздать базу — это дешевле, чем
|
||||||
отладка по данным, которых уже нет.
|
отладка по данным, которых уже нет.
|
||||||
|
|
||||||
### R6. ER-схема обновляется в том же изменении
|
### MIGR-6. ER-схема обновляется в том же изменении
|
||||||
|
|
||||||
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
|
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
|
||||||
изменением.
|
изменением.
|
||||||
@@ -99,7 +103,7 @@ down останавливает сразу и заставляет пересо
|
|||||||
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы,
|
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы,
|
||||||
а не язык приложения.
|
а не язык приложения.
|
||||||
|
|
||||||
### R7. Enum-поля — `TEXT`, допустимые значения держит код
|
### MIGR-7. Enum-поля — `TEXT`, допустимые значения держит код
|
||||||
|
|
||||||
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
|
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
|
||||||
без `CHECK`-ограничения на список значений.
|
без `CHECK`-ограничения на список значений.
|
||||||
@@ -115,7 +119,7 @@ down останавливает сразу и заставляет пересо
|
|||||||
таблицы соответствия, которую пришлось бы держать в голове для числового
|
таблицы соответствия, которую пришлось бы держать в голове для числового
|
||||||
кода.
|
кода.
|
||||||
|
|
||||||
### R8. Метки времени — `TEXT` в формате из `arch/time.md`
|
### MIGR-8. Метки времени — `TEXT` в формате из `arch/time.md`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
|
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
|
||||||
пишутся в формате из `arch/time.md`.
|
пишутся в формате из `arch/time.md`.
|
||||||
@@ -127,7 +131,7 @@ down останавливает сразу и заставляет пересо
|
|||||||
преобразования, а значит и без потери индекса. Соседство двух форматов в
|
преобразования, а значит и без потери индекса. Соседство двух форматов в
|
||||||
одной колонке ломает и сравнение, и разбор на стороне Go.
|
одной колонке ломает и сравнение, и разбор на стороне Go.
|
||||||
|
|
||||||
### R9. Умолчание `DEFAULT (datetime('now'))` не ставится
|
### MIGR-9. Умолчание `DEFAULT (datetime('now'))` не ставится
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
|
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
|
||||||
на уровне схемы.
|
на уровне схемы.
|
||||||
@@ -138,10 +142,10 @@ down останавливает сразу и заставляет пересо
|
|||||||
по ошибке.
|
по ошибке.
|
||||||
|
|
||||||
Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`,
|
Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`,
|
||||||
то есть не тот формат, которого требует R8. В колонке оказываются строки
|
то есть не тот формат, которого требует MIGR-8. В колонке оказываются строки
|
||||||
двух видов, и ломается ровно то, ради чего формат выбран.
|
двух видов, и ломается ровно то, ради чего формат выбран.
|
||||||
|
|
||||||
### R10. Булевы поля — `INTEGER` со значениями 0 и 1
|
### MIGR-10. Булевы поля — `INTEGER` со значениями 0 и 1
|
||||||
|
|
||||||
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
|
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
|
||||||
|
|
||||||
@@ -152,32 +156,33 @@ down останавливает сразу и заставляет пересо
|
|||||||
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
|
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
|
||||||
проверяют, что список не пуст.
|
проверяют, что список не пуст.
|
||||||
|
|
||||||
### R11. Вид первичного ключа задаёт `arch/db-identifiers.md`
|
### MIGR-11. Первичный ключ новой таблицы — TEXT ULID
|
||||||
|
|
||||||
**ДОЛЖЕН.** В репозитории, подписанном на `arch/db-identifiers.md`, вид
|
**ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из
|
||||||
ключа выбирается по её R1, и `AUTOINCREMENT` в миграции не пишется.
|
приложения (`KEYS-1`, `KEYS-2`).
|
||||||
|
|
||||||
**Почему.** Вопрос о виде ключа решается один раз на репозиторий
|
**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение,
|
||||||
(`arch/db-identifiers.md` R1). Повторив здесь его ветвление, мы завели бы
|
принятое в `arch/db-identifiers.md`. Повторить там ветвление или условие
|
||||||
второй источник правды, и соседние таблицы разъехались бы по разным
|
значило бы завести второй источник правды, и соседние таблицы разъехались бы
|
||||||
ответам на один и тот же вопрос.
|
по разным ответам на один вопрос.
|
||||||
|
|
||||||
`AUTOINCREMENT` не нужен ни в одной из веток R1. Строкового ключа он не
|
`AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на
|
||||||
касается вовсе, а целочисленному даёт единственную гарантию — что значение
|
`INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего.
|
||||||
rowid не будет переиспользовано после удаления строки, — ценой служебной
|
|
||||||
таблицы `sqlite_sequence` и записи в неё на каждой вставке. Гарантия эта
|
|
||||||
имеет смысл, только если старые идентификаторы живут где-то вне базы.
|
|
||||||
|
|
||||||
### R12. Вне `arch/db-identifiers.md` первичный ключ — автоинкремент
|
### MIGR-12. Целочисленный ключ идёт вместе с `AUTOINCREMENT`
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Репозиторий, не подписанный на `arch/db-identifiers.md`,
|
**ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая
|
||||||
берёт целочисленный автоинкрементный ключ.
|
схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`.
|
||||||
|
|
||||||
**Почему.** Явное разрешение нужно, чтобы R11 не читался как требование
|
**Почему.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после
|
||||||
подписаться на `arch/db-identifiers.md`. Выбор вида ключа — решение уровня
|
удаления последней строки номер переиспользуется. Протухшая ссылка на
|
||||||
репозитория, и конвенция про типы колонок его за репозиторий не принимает;
|
удалённую запись — из закладки, из чужой таблицы, из старого лога — молча
|
||||||
приложению, сущности которого не адресуют снаружи, целочисленный ключ
|
наводится на другую сущность и возвращает правдоподобный, но чужой ответ.
|
||||||
ничего не стоит.
|
Обнаружить это по данным нельзя: обе строки валидны.
|
||||||
|
|
||||||
|
Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой
|
||||||
|
вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает:
|
||||||
|
там ключ строковый (MIGR-11).
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
@@ -1,27 +1,42 @@
|
|||||||
|
---
|
||||||
|
prefix: GERR
|
||||||
|
---
|
||||||
|
|
||||||
# Ошибки
|
# Ошибки
|
||||||
|
|
||||||
Как ошибки строятся, оборачиваются и проверяются. Форма записи —
|
Как ошибки строятся, оборачиваются и проверяются. Форма записи —
|
||||||
`common/language.md`. Где и когда ошибку **логировать** — в
|
`LANGUAGE.md`. Где и когда ошибку **логировать** — в
|
||||||
`lang/go/logging.md` (коротко: лог один раз на доменной границе).
|
`lang/go/logging.md` (коротко: лог один раз на доменной границе).
|
||||||
|
|
||||||
|
Две границы, о которых говорят правила ниже:
|
||||||
|
|
||||||
|
- **доменная граница** — место, где определяется исход операции: use-case,
|
||||||
|
публичная команда воркера, стадия асинхронной обработки. Ниже неё ошибка
|
||||||
|
только накапливает контекст, выше — операция уже либо удалась, либо нет.
|
||||||
|
- **внешняя граница** — место, где ответ покидает процесс: обработчик HTTP,
|
||||||
|
рендер страницы, отправка сообщения ботом.
|
||||||
|
|
||||||
|
Одна операция проходит обе: сначала доменную (там её исход логируется),
|
||||||
|
потом внешнюю (там он превращается в ответ).
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Ошибки строятся средствами стандартной библиотеки
|
### GERR-1. Ошибки строятся средствами стандартной библиотеки
|
||||||
|
|
||||||
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
|
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
|
||||||
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
|
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
|
||||||
|
|
||||||
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
|
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
|
||||||
При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений
|
При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
|
||||||
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
|
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
|
||||||
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
|
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
|
||||||
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
|
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
|
||||||
это цена без покупателя.
|
это цена без покупателя.
|
||||||
|
|
||||||
Единственное место, где стек всё-таки нужен, — восстановленная паника: у
|
Единственное место, где стек всё-таки нужен, — восстановленная паника: у
|
||||||
неё цепочки `%w` нет вовсе (R23).
|
неё цепочки `%w` нет вовсе (GERR-23).
|
||||||
|
|
||||||
### R2. Дефолт не обходится точечно
|
### GERR-2. Дефолт не обходится точечно
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
|
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
|
||||||
кодовой базы ради конкретной отладки.
|
кодовой базы ради конкретной отладки.
|
||||||
@@ -30,39 +45,39 @@
|
|||||||
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
|
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
|
||||||
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
|
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
|
||||||
локально, перестаёт накапливаться — а накопление и есть единственный
|
локально, перестаёт накапливаться — а накопление и есть единственный
|
||||||
сигнал, что решение R1 пора пересматривать целиком.
|
сигнал, что решение GERR-1 пора пересматривать целиком.
|
||||||
|
|
||||||
### R3. Каждый слой добавляет свой контекст
|
### GERR-3. Каждый слой добавляет свой контекст
|
||||||
|
|
||||||
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
|
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
|
||||||
контекстом: `fmt.Errorf("parse magnet: %w", err)`.
|
контекстом: `fmt.Errorf("parse magnet: %w", err)`.
|
||||||
|
|
||||||
**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько,
|
**Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
|
||||||
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
|
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
|
||||||
стирает участок пути — по итоговому сообщению нельзя сказать, через какую
|
стирает участок пути — по итоговому сообщению нельзя сказать, через какую
|
||||||
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
|
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
|
||||||
кода.
|
кода.
|
||||||
|
|
||||||
### R4. Обёртка по умолчанию — `%w`
|
### GERR-4. Обёртка по умолчанию — `%w`
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
|
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
|
||||||
вызывающему:
|
вызывающему:
|
||||||
|
|
||||||
| № | Ситуация | Глагол |
|
| № | Ситуация | Глагол |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
|
| GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
|
||||||
| R4.2 | причину сознательно не раскрываем | `%v` |
|
| GERR-4.2 | причину сознательно не раскрываем | `%v` |
|
||||||
|
|
||||||
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
|
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
|
||||||
становится частью API» — относится к библиотекам с внешними потребителями.
|
становится частью API» — относится к библиотекам с внешними потребителями.
|
||||||
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
|
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
|
||||||
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
|
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
|
||||||
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
|
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
|
||||||
(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
|
(GERR-10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
|
||||||
`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда
|
`ErrNotFound`», далеко от места обрыва. GERR-4.2 остаётся для случая, когда
|
||||||
завязывать вызывающего на чужой тип ошибки не хотят намеренно.
|
завязывать вызывающего на чужой тип ошибки не хотят намеренно.
|
||||||
|
|
||||||
### R5. Утечка внутренних деталей лечится трансляцией, а не `%v`
|
### GERR-5. Утечка внутренних деталей лечится трансляцией, а не `%v`
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
|
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
|
||||||
ошибку наружу.
|
ошибку наружу.
|
||||||
@@ -71,9 +86,9 @@
|
|||||||
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
|
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
|
||||||
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
|
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
|
||||||
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
|
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
|
||||||
Настоящее место защиты — R13.
|
Настоящее место защиты — GERR-13.
|
||||||
|
|
||||||
### R6. Текст обёртки — со строчной буквы и без служебных слов
|
### GERR-6. Текст обёртки — со строчной буквы и без служебных слов
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
|
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
|
||||||
|
|
||||||
@@ -83,15 +98,15 @@
|
|||||||
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
|
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
|
||||||
каждом уровне и вытесняют из строки полезный контекст.
|
каждом уровне и вытесняют из строки полезный контекст.
|
||||||
|
|
||||||
### R7. Контекст обёртки называет операцию или субъект
|
### GERR-7. Контекст обёртки называет операцию или субъект
|
||||||
|
|
||||||
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
|
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
|
||||||
|
|
||||||
**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something
|
**Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
|
||||||
failed» не сужает ничего и при этом занимает в сообщении место, которое мог
|
failed» не сужает ничего и при этом занимает в сообщении место, которое мог
|
||||||
бы занять единственный полезный здесь факт — имя операции.
|
бы занять единственный полезный здесь факт — имя операции.
|
||||||
|
|
||||||
### R8. Слой не повторяет смысл нижнего
|
### GERR-8. Слой не повторяет смысл нижнего
|
||||||
|
|
||||||
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
|
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
|
||||||
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
|
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
|
||||||
@@ -104,10 +119,10 @@ failed» не сужает ничего и при этом занимает в
|
|||||||
## Две трансляции
|
## Две трансляции
|
||||||
|
|
||||||
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
|
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
|
||||||
доменная у источника (R9) и доменная → пользовательская на внешней границе
|
доменная у источника (GERR-9) и доменная → пользовательская на внешней границе
|
||||||
(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
|
(GERR-13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
|
||||||
|
|
||||||
### R9. Инфраструктурная ошибка транслируется в доменную у источника
|
### GERR-9. Инфраструктурная ошибка транслируется в доменную у источника
|
||||||
|
|
||||||
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
|
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
|
||||||
возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для
|
возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для
|
||||||
@@ -120,14 +135,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
состояние «нет записи» одно и то же. Трансляция у источника оставляет
|
состояние «нет записи» одно и то же. Трансляция у источника оставляет
|
||||||
знание о зависимости в единственном слое, который её и так знает.
|
знание о зависимости в единственном слое, который её и так знает.
|
||||||
|
|
||||||
### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
|
### GERR-10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
|
||||||
|
|
||||||
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
|
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
|
||||||
|
|
||||||
| № | Что нужно вызывающему | Форма |
|
| № | Что нужно вызывающему | Форма |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
|
| GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
|
||||||
| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
|
| GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
|
||||||
|
|
||||||
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от
|
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от
|
||||||
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
|
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
|
||||||
@@ -136,14 +151,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
каждой проверке. Две формы для одного условия — это два способа его
|
каждой проверке. Две формы для одного условия — это два способа его
|
||||||
проверить, и про второй рано или поздно забудут.
|
проверить, и про второй рано или поздно забудут.
|
||||||
|
|
||||||
### R11. Матчинг по тексту сообщения
|
### GERR-11. Матчинг по тексту сообщения
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
|
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
|
||||||
|
|
||||||
**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его
|
**Почему.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают
|
||||||
свободно. Правка формулировки в нижнем слое молча ломает ветвление
|
переписывать его свободно. Правка формулировки в нижнем слое молча ломает
|
||||||
наверху, и компилятор этого не видит. Это то же самое, что публичный API из
|
ветвление наверху, и компилятор этого не видит. Это то же самое, что
|
||||||
строки лога.
|
публичный API из строки лога.
|
||||||
|
|
||||||
## Граница: приватный канал и публичный
|
## Граница: приватный канал и публичный
|
||||||
|
|
||||||
@@ -151,39 +166,39 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
|
того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
|
||||||
публичный — пользовательские поверхности (HTTP API, web-UI, бот).
|
публичный — пользовательские поверхности (HTTP API, web-UI, бот).
|
||||||
|
|
||||||
### R12. Полная ошибка идёт в приватный канал
|
### GERR-12. Полная ошибка идёт в приватный канал
|
||||||
|
|
||||||
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
|
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
|
||||||
— `lang/go/logging.md`.
|
— `lang/go/logging.md`.
|
||||||
|
|
||||||
**Почему.** Цепочка — единственный носитель диагностики (R1), и
|
**Почему.** Цепочка — единственный носитель диагностики (GERR-1), и
|
||||||
единственный канал, где её можно показать целиком, — тот, который видит
|
единственный канал, где её можно показать целиком, — тот, который видит
|
||||||
владелец. Не записанная там, она не сохранится нигде: наружу идёт
|
владелец. Не записанная там, она не сохранится нигде: наружу идёт
|
||||||
нейтральное сообщение (R13), и восстанавливать причину будет не из чего.
|
нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
|
||||||
|
|
||||||
### R13. Публичная поверхность получает сообщение по доменной ошибке
|
### GERR-13. Публичная поверхность получает сообщение по доменной ошибке
|
||||||
|
|
||||||
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
|
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
|
||||||
`err.Error()` и не детали реализации (`database/sql`, пути, стек).
|
`err.Error()` и не детали реализации (`database/sql`, пути, стек).
|
||||||
|
|
||||||
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
|
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
|
||||||
— у него есть лог (R12). Зато они раскрывают устройство системы — имена
|
— у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена
|
||||||
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
|
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
|
||||||
причём раскрывают именно в момент, когда что-то пошло не так.
|
причём раскрывают именно в момент, когда что-то пошло не так.
|
||||||
|
|
||||||
### R14. Публичное сообщение несёт корреляционный ключ
|
### GERR-14. Публичное сообщение несёт корреляционный ключ
|
||||||
|
|
||||||
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
|
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
|
||||||
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
|
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
|
||||||
ошибка».
|
ошибка».
|
||||||
|
|
||||||
**Почему.** R13 забирает у пользователя всю фактуру; без ключа его
|
**Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его
|
||||||
обращение звучит как «у меня что-то не работает», и владелец ищет запись в
|
обращение звучит как «у меня что-то не работает», и владелец ищет запись в
|
||||||
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
|
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
|
||||||
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
|
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
|
||||||
видел.
|
видел.
|
||||||
|
|
||||||
### R15. Маппинг доменных ошибок — в одной точке на все транспорты
|
### GERR-15. Маппинг доменных ошибок — в одной точке на все транспорты
|
||||||
|
|
||||||
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
|
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
|
||||||
задаётся один раз; транспорт без статусов (бот) берёт из него только
|
задаётся один раз; транспорт без статусов (бот) берёт из него только
|
||||||
@@ -192,13 +207,13 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
|
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
|
||||||
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
|
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
|
||||||
важнее: единственная точка — это место, куда механически дописывается новая
|
важнее: единственная точка — это место, куда механически дописывается новая
|
||||||
ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде»
|
ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде»
|
||||||
ничем не проверяет.
|
ничем не проверяет.
|
||||||
|
|
||||||
### R16. Новая штатная ветвь отказа сразу попадает в маппинг
|
### GERR-16. Новая штатная ветвь отказа сразу попадает в маппинг
|
||||||
|
|
||||||
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
|
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
|
||||||
добавляется в маппинг (R15) тем же изменением.
|
добавляется в маппинг (GERR-15) тем же изменением.
|
||||||
|
|
||||||
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
|
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
|
||||||
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
|
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
|
||||||
@@ -208,18 +223,37 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
<!-- local:маппинг -->
|
<!-- local:маппинг -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
### R17. Форма текста определяется поверхностью
|
### GERR-25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт
|
||||||
|
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
|
||||||
|
признаком того, что маппинг её не знает.
|
||||||
|
|
||||||
|
**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
|
||||||
|
завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
|
||||||
|
`ERROR` — уровень выбирается по адресату (`lang/go/logging.md`). Статус
|
||||||
|
тоже не выбирается: известное пользовательское состояние лежало бы в
|
||||||
|
маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx
|
||||||
|
отпадает.
|
||||||
|
|
||||||
|
Признак нужен потому, что без него забытая ветвь неотличима от упавшей
|
||||||
|
базы: обе дают `ERROR` с текстом ошибки, и наткнуться на пропуск можно
|
||||||
|
только случайно. Отдельное поле или своя категория сообщения делают пропуск
|
||||||
|
находимым одним фильтром — и тогда громкость 500 и `ERROR` работает как
|
||||||
|
механизм обнаружения, а не как шум.
|
||||||
|
|
||||||
|
### GERR-17. Форма текста определяется поверхностью
|
||||||
|
|
||||||
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
|
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
|
||||||
текста для них разное:
|
текста для них разное:
|
||||||
|
|
||||||
| № | Поверхность | Текст ошибки |
|
| № | Поверхность | Текст ошибки |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт |
|
| GERR-17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (GERR-15); `err.Error()` наружу не идёт |
|
||||||
| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
|
| GERR-17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
|
||||||
|
|
||||||
Появился второй зритель или публичный доступ к экрану состояния —
|
Появился второй зритель или публичный доступ к экрану состояния —
|
||||||
поверхность стала публичным каналом, и на неё распространяется R17.1.
|
поверхность стала публичным каналом, и на неё распространяется GERR-17.1.
|
||||||
|
|
||||||
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
|
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
|
||||||
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
|
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
|
||||||
@@ -229,7 +263,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
про единственного зрителя — ровно то, что делает вторую поверхность
|
про единственного зрителя — ровно то, что делает вторую поверхность
|
||||||
приватным каналом; без него это обычная публичная поверхность.
|
приватным каналом; без него это обычная публичная поверхность.
|
||||||
|
|
||||||
### R18. Секретов нет ни на одной из поверхностей
|
### GERR-18. Секретов нет ни на одной из поверхностей
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
|
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
|
||||||
ни в персистентную диагностику; источник вычищается на границе клиента.
|
ни в персистентную диагностику; источник вычищается на границе клиента.
|
||||||
@@ -240,19 +274,19 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
известно, какие поля запроса секретны: дальше ошибка едет как текст, и
|
известно, какие поля запроса секретны: дальше ошибка едет как текст, и
|
||||||
отличить в нём токен от идентификатора уже нельзя.
|
отличить в нём токен от идентификатора уже нельзя.
|
||||||
|
|
||||||
### R19. Диагностика хранится в отдельном поле
|
### GERR-19. Диагностика хранится в отдельном поле
|
||||||
|
|
||||||
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
|
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
|
||||||
показывают пользователю.
|
показывают пользователю.
|
||||||
|
|
||||||
**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей
|
**Почему.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей
|
||||||
разные поля. Одно поле на оба назначения означает, что при первом же показе
|
разные поля. Одно поле на оба назначения означает, что при первом же показе
|
||||||
записи наружу сырой текст уедет туда же — не по решению, а потому что поле
|
записи наружу сырой текст уедет туда же — не по решению, а потому что поле
|
||||||
одно.
|
одно.
|
||||||
|
|
||||||
## panic
|
## panic
|
||||||
|
|
||||||
### R20. `panic` — только для невосстановимого
|
### GERR-20. `panic` — только для невосстановимого
|
||||||
|
|
||||||
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
|
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
|
||||||
ошибка инициализации, из которой нельзя стартовать.
|
ошибка инициализации, из которой нельзя стартовать.
|
||||||
@@ -263,25 +297,25 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
инвариантом опаснее падения, а сервис, стартовавший без обязательной
|
инвариантом опаснее падения, а сервис, стартовавший без обязательной
|
||||||
зависимости, всё равно откажет позже и непонятнее.
|
зависимости, всё равно откажет позже и непонятнее.
|
||||||
|
|
||||||
### R21. Ожидаемые ошибки — значения `error`
|
### GERR-21. Ожидаемые ошибки — значения `error`
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
|
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
|
||||||
плохой ввод, отсутствующая запись возвращаются как `error`.
|
плохой ввод, отсутствующая запись возвращаются как `error`.
|
||||||
|
|
||||||
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
|
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
|
||||||
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
|
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
|
||||||
его обработать. Дальше такая паника долетает до recover-границы (R22), где
|
его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где
|
||||||
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
|
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
|
||||||
«мы сломались».
|
«мы сломались».
|
||||||
|
|
||||||
### R22. `recover` — на верхней границе каждой обрабатывающей единицы
|
### GERR-22. `recover` — на верхней границе каждой обрабатывающей единицы
|
||||||
|
|
||||||
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
|
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
|
||||||
|
|
||||||
| № | Единица | Зачем `recover` |
|
| № | Единица | Зачем `recover` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
|
| GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
|
||||||
| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
|
| GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
|
||||||
|
|
||||||
**Почему.** `recover` работает только в той горутине, где случилась паника,
|
**Почему.** `recover` работает только в той горутине, где случилась паника,
|
||||||
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
|
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
|
||||||
@@ -291,18 +325,54 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
без своего `recover` уходит мимо структурированного лога, а клиент получает
|
без своего `recover` уходит мимо структурированного лога, а клиент получает
|
||||||
оборванное соединение вместо ответа.
|
оборванное соединение вместо ответа.
|
||||||
|
|
||||||
### R23. Recover-граница пишет `debug.Stack()`
|
### GERR-23. Recover-граница пишет `debug.Stack()`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
|
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
|
||||||
|
|
||||||
**Почему.** Это единственное место, где стек нужен (R1): у восстановленной
|
**Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной
|
||||||
паники цепочки `%w` нет вовсе. «index out of range» без стека не
|
паники цепочки `%w` нет вовсе. «index out of range» без стека не
|
||||||
диагностируется в принципе — сообщение не называет ни файла, ни операции,
|
диагностируется в принципе — сообщение не называет ни файла, ни операции,
|
||||||
по нему нельзя сказать даже, в каком пакете упало.
|
по нему нельзя сказать даже, в каком пакете упало.
|
||||||
|
|
||||||
## Несколько ошибок
|
## Несколько ошибок
|
||||||
|
|
||||||
### R24. Независимые ошибки собираются `errors.Join`
|
### GERR-26. После `recover` единица продолжает работу, исключив упавшее
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Что происходит после перехвата, зависит от того, где стоит
|
||||||
|
граница:
|
||||||
|
|
||||||
|
| № | Где перехвачена паника | Что дальше |
|
||||||
|
|---|---|---|
|
||||||
|
| GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
|
||||||
|
| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
|
||||||
|
|
||||||
|
**Почему.** Паника внутри обработки одного элемента почти всегда говорит о
|
||||||
|
баге в работе с данными этого элемента, а не о порче общего состояния, —
|
||||||
|
останавливать всё остальное не за что. Довод «let it crash» здесь работает
|
||||||
|
не буквально: в OTP падает изолированный процесс под супервизором, а не узел
|
||||||
|
целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не
|
||||||
|
граница процесса. Обратное при этом верно и делает `recover` в цикле
|
||||||
|
обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь
|
||||||
|
процесс.
|
||||||
|
|
||||||
|
Продолжать, не исключив упавший элемент, нельзя: детерминированная паника
|
||||||
|
даёт бесконечный цикл — тот же элемент, тот же стек, залитый лог и нулевой
|
||||||
|
прогресс. Это классический poison message, и лекарство берём то же, что
|
||||||
|
принято в очередях: элемент выводится из оборота, а не берётся снова. У
|
||||||
|
цикла, который и так подтверждает прогресс — сдвигает офсет, помечает
|
||||||
|
строку состоянием, — механизм для этого уже есть, заводить отдельный не
|
||||||
|
нужно.
|
||||||
|
|
||||||
|
Оговорка «если ответ ещё не начат» в GERR-26.1 не формальность: статус
|
||||||
|
отправляется один раз, и после первой записи в тело поменять его нечем —
|
||||||
|
клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать
|
||||||
|
ответ целиком до записи там, где это возможно.
|
||||||
|
|
||||||
|
Из GERR-26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать
|
||||||
|
обработку намеренно», и recover-обёртка пробрасывает его дальше, а не
|
||||||
|
превращает в 500. Так поступают и стандартные обёртки вроде chi.
|
||||||
|
|
||||||
|
### GERR-24. Независимые ошибки собираются `errors.Join`
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
|
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
|
||||||
разом; проверка собранного — по-прежнему через `errors.Is`.
|
разом; проверка собранного — по-прежнему через `errors.Is`.
|
||||||
@@ -311,12 +381,13 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
|||||||
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
|
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
|
||||||
тот же список, но убивает ветвление: `errors.Is` по такому результату не
|
тот же список, но убивает ветвление: `errors.Is` по такому результату не
|
||||||
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
|
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
|
||||||
(R11).
|
(GERR-11).
|
||||||
|
|
||||||
## Связано
|
## Связано
|
||||||
|
|
||||||
- `lang/go/logging.md` — где и когда ошибка попадает в лог.
|
- `lang/go/logging.md` — где и когда ошибка попадает в лог.
|
||||||
- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14.
|
- `KEYS-7` (`arch/db-identifiers.md`) — формат корреляционного ключа
|
||||||
|
из `GERR-14`.
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
prefix: SLOG
|
||||||
extends: arch/time.md
|
extends: arch/time.md
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ extends: arch/time.md
|
|||||||
|
|
||||||
Как и когда писать логи. Это правила оформления кода (How), а не
|
Как и когда писать логи. Это правила оформления кода (How), а не
|
||||||
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
|
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
|
||||||
функциональности, живут в спеках. Форма записи — `common/language.md`.
|
функциональности, живут в спеках. Форма записи — `LANGUAGE.md`.
|
||||||
|
|
||||||
Лог читают инструментами, а не глазами: повседневно — `jq`
|
Лог читают инструментами, а не глазами: повседневно — `jq`
|
||||||
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
|
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
|
||||||
@@ -19,7 +20,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в
|
|||||||
|
|
||||||
## Формат записи
|
## Формат записи
|
||||||
|
|
||||||
### R1. Структурированный JSON, один формат для dev и prod
|
### SLOG-1. Структурированный JSON, один формат для dev и prod
|
||||||
|
|
||||||
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
|
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
|
||||||
проде.
|
проде.
|
||||||
@@ -31,7 +32,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
значение) обнаруживаются только в проде, где заметить их заранее уже
|
значение) обнаруживаются только в проде, где заметить их заранее уже
|
||||||
некому.
|
некому.
|
||||||
|
|
||||||
### R2. Данные — в типизированных полях, а не в тексте сообщения
|
### SLOG-2. Данные — в типизированных полях, а не в тексте сообщения
|
||||||
|
|
||||||
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
|
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
правке формулировки. Тип важен отдельно от ключа: число внутри строки не
|
правке формулировки. Тип важен отдельно от ключа: число внутри строки не
|
||||||
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
|
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
|
||||||
|
|
||||||
### R3. Время записи — UTC
|
### SLOG-3. Время записи — UTC
|
||||||
|
|
||||||
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
|
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
|
||||||
(см. `lang/go/time.md`).
|
(см. `lang/go/time.md`).
|
||||||
@@ -58,7 +59,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
|
|
||||||
## Сообщение
|
## Сообщение
|
||||||
|
|
||||||
### R4. `msg` — константа в нижнем регистре
|
### SLOG-4. `msg` — константа в нижнем регистре
|
||||||
|
|
||||||
**ДОЛЖЕН.** Текст сообщения не собирается из переменных:
|
**ДОЛЖЕН.** Текст сообщения не собирается из переменных:
|
||||||
`log.Info("download accepted", "download_id", id)`.
|
`log.Info("download accepted", "download_id", id)`.
|
||||||
@@ -69,7 +70,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
одна категория не двоилась на варианты, различающиеся только заглавной
|
одна категория не двоилась на варианты, различающиеся только заглавной
|
||||||
буквой.
|
буквой.
|
||||||
|
|
||||||
### R5. `msg` не несёт префикса подсистемы
|
### SLOG-5. `msg` не несёт префикса подсистемы
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
|
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
|
||||||
отдельное поле.
|
отдельное поле.
|
||||||
@@ -80,7 +81,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
категория дробится на варианты с префиксом и без, а совпадать они обязаны
|
категория дробится на варианты с префиксом и без, а совпадать они обязаны
|
||||||
посимвольно.
|
посимвольно.
|
||||||
|
|
||||||
### R6. Смена состояния сущности — единая категория
|
### SLOG-6. Смена состояния сущности — единая категория
|
||||||
|
|
||||||
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
|
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
|
||||||
состояние и по какой причине — данные, а не текст.
|
состояние и по какой причине — данные, а не текст.
|
||||||
@@ -91,37 +92,37 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
останется неполной. Единая категория даёт весь цикл одним фильтром и не
|
останется неполной. Единая категория даёт весь цикл одним фильтром и не
|
||||||
требует обновлять запрос вслед за кодом.
|
требует обновлять запрос вслед за кодом.
|
||||||
|
|
||||||
### R7. Физический эффект — отдельная запись, а не вместо перехода
|
### SLOG-7. Физический эффект — отдельная запись, а не вместо перехода
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
|
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
|
||||||
запись самого перехода.
|
запись самого перехода.
|
||||||
|
|
||||||
**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых
|
**Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
|
||||||
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
|
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
|
||||||
строки в логе; восстановление пропущенного перехода не стоит ничего, потому
|
строки в логе; восстановление пропущенного перехода не стоит ничего, потому
|
||||||
что невозможно.
|
что невозможно.
|
||||||
|
|
||||||
## Уровни
|
## Уровни
|
||||||
|
|
||||||
### R8. Уровень выбирается по адресату
|
### SLOG-8. Уровень выбирается по адресату
|
||||||
|
|
||||||
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
|
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
|
||||||
громко сломалось».
|
громко сломалось».
|
||||||
|
|
||||||
| № | Уровень | Кому и когда |
|
| № | Уровень | Кому и когда |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
|
| SLOG-8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
|
||||||
| R8.2 | `INFO` | владельцу, аудит постфактум |
|
| SLOG-8.2 | `INFO` | владельцу, аудит постфактум |
|
||||||
| R8.3 | `WARN` | владельцу, «может стать проблемой» |
|
| SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» |
|
||||||
| R8.4 | `ERROR` | владельцу, в разбор |
|
| SLOG-8.4 | `ERROR` | владельцу, в разбор |
|
||||||
|
|
||||||
**Почему.** Адресат — единственный признак, по которому разные авторы в
|
**Почему.** Адресат — единственный признак, по которому разные авторы в
|
||||||
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
|
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
|
||||||
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
|
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
|
||||||
базовый порог в проде (R40), потому что он отсекает уже не то, что
|
базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что
|
||||||
задумано.
|
задумано.
|
||||||
|
|
||||||
### R9. Уровень не зависит от подсистемы
|
### SLOG-9. Уровень не зависит от подсистемы
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
|
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
|
||||||
везде одинаково серьёзен.
|
везде одинаково серьёзен.
|
||||||
@@ -132,7 +133,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
уровень перестаёт быть фильтром и становится подсказкой, требующей знания
|
уровень перестаёт быть фильтром и становится подсказкой, требующей знания
|
||||||
кода.
|
кода.
|
||||||
|
|
||||||
### R10. `WARN` — только когда «может стать проблемой»
|
### SLOG-10. `WARN` — только когда «может стать проблемой»
|
||||||
|
|
||||||
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
|
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
|
||||||
|
|
||||||
@@ -141,22 +142,22 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
единственное, ради чего уровень существует: предупреждение, на которое ещё
|
единственное, ради чего уровень существует: предупреждение, на которое ещё
|
||||||
есть время отреагировать.
|
есть время отреагировать.
|
||||||
|
|
||||||
### R11. Событийное — `INFO`, рутинно-частое — `DEBUG`
|
### SLOG-11. Событийное — `INFO`, рутинно-частое — `DEBUG`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие.
|
**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие.
|
||||||
|
|
||||||
| № | Операция | Уровень |
|
| № | Операция | Уровень |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R11.1 | по реальному действию или изменению | `INFO` |
|
| SLOG-11.1 | по реальному действию или изменению | `INFO` |
|
||||||
| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
|
| SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
|
||||||
|
|
||||||
**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность
|
**Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
|
||||||
определяется долей записей, за которыми что-то стоит. Периодическая
|
определяется долей записей, за которыми что-то стоит. Периодическая
|
||||||
операция даёт ровный поток при нулевой информации, в котором настоящие
|
операция даёт ровный поток при нулевой информации, в котором настоящие
|
||||||
события тонут количественно: их не отфильтровать, потому что фильтровать
|
события тонут количественно: их не отфильтровать, потому что фильтровать
|
||||||
приходится по содержанию, а не по уровню.
|
приходится по содержанию, а не по уровню.
|
||||||
|
|
||||||
### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
|
### SLOG-12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
|
||||||
|
|
||||||
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую
|
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую
|
||||||
степень даёт завершение процесса.
|
степень даёт завершение процесса.
|
||||||
@@ -169,7 +170,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
|
|
||||||
## Поля: единый словарь
|
## Поля: единый словарь
|
||||||
|
|
||||||
### R13. Одно поле — одно имя по всему коду
|
### SLOG-13. Одно поле — одно имя по всему коду
|
||||||
|
|
||||||
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
|
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
|
||||||
|
|
||||||
@@ -178,14 +179,14 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
часть записей в него не попадёт, и заметить это можно, только заранее зная,
|
часть записей в него не попадёт, и заметить это можно, только заранее зная,
|
||||||
что они должны были быть.
|
что они должны были быть.
|
||||||
|
|
||||||
### R14. Форма имени зависит от вида поля
|
### SLOG-14. Форма имени зависит от вида поля
|
||||||
|
|
||||||
**ДОЛЖЕН.** Две формы, третьей нет.
|
**ДОЛЖЕН.** Две формы, третьей нет.
|
||||||
|
|
||||||
| № | Вид поля | Форма имени |
|
| № | Вид поля | Форма имени |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
|
| SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
|
||||||
| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
|
| SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
|
||||||
|
|
||||||
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
|
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
|
||||||
в любом проекте, от доменных, которые в каждом свои: по общему префиксу
|
в любом проекте, от доменных, которые в каждом свои: по общему префиксу
|
||||||
@@ -193,7 +194,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
|
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
|
||||||
названо, и спорить о них на каждом ревью.
|
названо, и спорить о них на каждом ревью.
|
||||||
|
|
||||||
### R15. Запись плоская
|
### SLOG-15. Запись плоская
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
|
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
|
||||||
имени, а не уровень вложенности.
|
имени, а не уровень вложенности.
|
||||||
@@ -203,30 +204,32 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
|
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
|
||||||
весь лог, распадаясь на запрос под каждую форму записи.
|
весь лог, распадаясь на запрос под каждую форму записи.
|
||||||
|
|
||||||
### R16. Набор полей определяется ситуацией
|
### SLOG-16. Набор полей определяется ситуацией
|
||||||
|
|
||||||
**ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком.
|
**ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком.
|
||||||
|
|
||||||
| № | Когда добавляем | Поля |
|
| № | Когда добавляем | Поля |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — если транспортов больше одного |
|
| SLOG-16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — пока его значение различается между записями (SLOG-17) |
|
||||||
| R16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
|
| SLOG-16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
|
||||||
| R16.3 | запись об ошибке | `error` |
|
| SLOG-16.3 | запись об ошибке | `error` |
|
||||||
| R16.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` не отделяет «легла зависимость» от «у нас
|
||||||
баг», запись о сущности без идентификатора не корреллируется (R19). Полный
|
баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный
|
||||||
набор делает записи однородными — один запрос работает по всем вызовам, а
|
набор делает записи однородными — один запрос работает по всем вызовам, а
|
||||||
не по тем, где автор вспомнил про поле.
|
не по тем, где автор вспомнил про поле.
|
||||||
|
|
||||||
### R17. `service.*` и `host.*` не заводим
|
### SLOG-17. `service.*` и `host.*` не заводим
|
||||||
|
|
||||||
**НЕ СЛЕДУЕТ.** Пока это один бинарь на одном хосте.
|
**НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не
|
||||||
|
заводится — для одного бинаря на одном хосте это `service.*` и `host.*`.
|
||||||
|
|
||||||
**Почему.** Поле с одним и тем же значением во всех записях не несёт
|
**Почему.** Такое поле не несёт информации, но стоит места в каждой строке
|
||||||
информации, но стоит места в каждой строке и внимания при чтении. Условие
|
и внимания при чтении. Критерий один на все поля словаря — им же решается,
|
||||||
|
нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие
|
||||||
названо явно, поэтому правило отпадёт вместе со своей причиной: с
|
названо явно, поэтому правило отпадёт вместе со своей причиной: с
|
||||||
появлением нескольких инстансов различающее поле (`service.version`)
|
появлением нескольких инстансов различающее поле (`service.version`)
|
||||||
добавляется одной строкой при старте.
|
добавляется одной строкой при старте.
|
||||||
@@ -236,7 +239,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
|
|
||||||
## Корреляция
|
## Корреляция
|
||||||
|
|
||||||
### R18. Ключ корреляции — идентификатор сущности, а не `trace_id`
|
### SLOG-18. Ключ корреляции — идентификатор сущности, а не `trace_id`
|
||||||
|
|
||||||
**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у
|
**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у
|
||||||
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
|
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
|
||||||
@@ -249,13 +252,13 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
способ спросить об одном. Условие применимости названо: там, где сущности
|
способ спросить об одном. Условие применимости названо: там, где сущности
|
||||||
со стабильным идентификатором нет, связывать записи больше нечем.
|
со стабильным идентификатором нет, связывать записи больше нечем.
|
||||||
|
|
||||||
### R19. Запись о сущности несёт её идентификатор
|
### SLOG-19. Запись о сущности несёт её идентификатор
|
||||||
|
|
||||||
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
|
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
|
||||||
|
|
||||||
**Почему.** Принадлежность записи восстанавливается только в момент
|
**Почему.** Принадлежность записи восстанавливается только в момент
|
||||||
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
|
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
|
||||||
Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем,
|
Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем,
|
||||||
что идентификатор стоит везде, а не в удобных местах.
|
что идентификатор стоит везде, а не в удобных местах.
|
||||||
|
|
||||||
Все записи одной операции собираются одним фильтром:
|
Все записи одной операции собираются одним фильтром:
|
||||||
@@ -263,7 +266,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
|||||||
глобально уникален across сущностей, штатно работает и простой `grep` по
|
глобально уникален across сущностей, штатно работает и простой `grep` по
|
||||||
голому значению — он находит все упоминания независимо от имени поля.
|
голому значению — он находит все упоминания независимо от имени поля.
|
||||||
|
|
||||||
### R20. Долгая операция ведётся scoped-логгером через `context.Context`
|
### SLOG-20. Долгая операция ведётся scoped-логгером через `context.Context`
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные
|
**СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные
|
||||||
стадии:
|
стадии:
|
||||||
@@ -280,17 +283,17 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
|||||||
|
|
||||||
## Ошибки
|
## Ошибки
|
||||||
|
|
||||||
### R21. Ошибка логируется атрибутом `error`
|
### SLOG-21. Ошибка логируется атрибутом `error`
|
||||||
|
|
||||||
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
|
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
|
||||||
|
|
||||||
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и
|
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
|
||||||
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
|
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
|
||||||
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
|
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
|
||||||
зависеть от того, кто писал конкретный вызов, и ради этого единообразия
|
зависеть от того, кто писал конкретный вызов, и ради этого единообразия
|
||||||
краткостью жертвуют.
|
краткостью жертвуют.
|
||||||
|
|
||||||
### R22. Промежуточный слой либо логирует, либо возвращает
|
### SLOG-22. Промежуточный слой либо логирует, либо возвращает
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
|
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
|
||||||
оборачивает (`%w`).
|
оборачивает (`%w`).
|
||||||
@@ -298,50 +301,59 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
|||||||
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
|
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
|
||||||
и количество `ERROR` перестаёт соответствовать количеству отказов — а
|
и количество `ERROR` перестаёт соответствовать количеству отказов — а
|
||||||
считают именно его. Контекст при этом не теряется: он накапливается в
|
считают именно его. Контекст при этом не теряется: он накапливается в
|
||||||
цепочке обёрток и попадает в единственную запись на границе (R23).
|
цепочке обёрток и попадает в единственную запись на границе (SLOG-23).
|
||||||
|
|
||||||
### R23. Ошибка логируется один раз — на границе доменного слоя
|
### SLOG-23. Ошибка логируется один раз — на границе доменного слоя
|
||||||
|
|
||||||
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
|
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
|
||||||
|
|
||||||
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
|
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
|
||||||
этим местом выбрана доменная граница, а не транспорт, потому что там
|
этим местом выбрана доменная граница, а не транспорт, потому что там
|
||||||
известен исход операции целиком и, значит, класс отказа (R25) — транспорт
|
известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт
|
||||||
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
|
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
|
||||||
транспорты остаются тонкими.
|
транспорты остаются тонкими.
|
||||||
|
|
||||||
<!-- local:границы -->
|
<!-- local:границы -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
### R24. Транспорт не логирует ошибку повторно
|
### SLOG-24. Транспорт не логирует ошибку повторно
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
|
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
|
||||||
(статус, сообщение пользователю) и на этом останавливается.
|
(статус, сообщение пользователю) и на этом останавливается.
|
||||||
|
|
||||||
**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё
|
**Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
|
||||||
только формулировкой и читается как второй сбой. Когда транспортов над
|
только формулировкой и читается как второй сбой. Когда транспортов над
|
||||||
одним доменом несколько, дублирование ещё и множится, а расследование
|
одним доменом несколько, дублирование ещё и множится, а расследование
|
||||||
начинается с вопроса, один это инцидент или два.
|
начинается с вопроса, один это инцидент или два.
|
||||||
|
|
||||||
### R25. Уровень доменного отказа — по классу отказа
|
### SLOG-25. Уровень доменного отказа — по классу отказа
|
||||||
|
|
||||||
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по
|
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (SLOG-23), и выбирает по
|
||||||
классу, а не по месту в коде.
|
классу, а не по месту в коде. Классификация покрывает **доменные** отказы —
|
||||||
|
те, что операция вернула значением `error`.
|
||||||
|
|
||||||
| № | Класс отказа | Кому | Уровень |
|
| № | Класс отказа | Кому | Уровень |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
|
| SLOG-25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
|
||||||
| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
| SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
||||||
| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
| SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
||||||
|
|
||||||
**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на
|
**Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
|
||||||
экране — владельцу разбирать нечего; целостность первичных данных отделяет
|
экране — владельцу разбирать нечего; целостность первичных данных отделяет
|
||||||
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
|
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
|
||||||
уровень для одного и того же отказа в зависимости от того, какой транспорт
|
уровень для одного и того же отказа в зависимости от того, какой транспорт
|
||||||
его вызвал, — и невалидный ввод из формы копился бы в `ERROR` наравне с
|
его вызвал, — и невалидный ввод из формы копился бы в `ERROR` наравне с
|
||||||
упавшей базой.
|
упавшей базой.
|
||||||
|
|
||||||
### R26. Тот же отказ в асинхронной стадии — уровнем выше
|
Нарушение инварианта в собственном коде — паника, недостижимая ветка — в
|
||||||
|
таблицу не входит: это не доменный отказ, и логирует его recover-граница
|
||||||
|
вместе со стеком (`lang/go/errors.md`). Искать его класс здесь не нужно.
|
||||||
|
|
||||||
|
Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё
|
||||||
|
нет, потому что её просто забыли завести. Она логируется `ERROR` с
|
||||||
|
признаком непокрытой (`GERR-25`).
|
||||||
|
|
||||||
|
### SLOG-26. Тот же отказ в асинхронной стадии — уровнем выше
|
||||||
|
|
||||||
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован
|
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован
|
||||||
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
|
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
|
||||||
@@ -352,7 +364,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
|||||||
никто, задача осталась недоведённой, и лог — единственное место, где это
|
никто, задача осталась недоведённой, и лог — единственное место, где это
|
||||||
вообще проявится.
|
вообще проявится.
|
||||||
|
|
||||||
### R27. Повторяющийся сбой фонового цикла — `WARN`
|
### SLOG-27. Повторяющийся сбой фонового цикла — `WARN`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
|
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
|
||||||
уровень задаёт наличие штатного повтора, а не текст ошибки.
|
уровень задаёт наличие штатного повтора, а не текст ошибки.
|
||||||
@@ -365,32 +377,32 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
|||||||
|
|
||||||
## Внешние сервисы
|
## Внешние сервисы
|
||||||
|
|
||||||
### R28. Каждый вызов внешнего сервиса логируется
|
### SLOG-28. Каждый вызов внешнего сервиса логируется
|
||||||
|
|
||||||
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4.
|
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4.
|
||||||
|
|
||||||
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость
|
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость
|
||||||
легла»: на своей стороне видно лишь то, что операция не удалась.
|
легла»: на своей стороне видно лишь то, что операция не удалась.
|
||||||
Выборочное логирование ломает и второе применение — доля неуспехов и
|
Выборочное логирование ломает и второе применение — доля неуспехов и
|
||||||
распределение `duration_ms` считаются, только если знаменатель полный.
|
распределение `duration_ms` считаются, только если знаменатель полный.
|
||||||
|
|
||||||
### R29. Уровень `ext`-записи — по исходу вызова
|
### SLOG-29. Уровень `ext`-записи — по исходу вызова
|
||||||
|
|
||||||
**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями.
|
**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями.
|
||||||
|
|
||||||
| № | Исход | Уровень |
|
| № | Исход | Уровень |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R29.1 | успешный событийный вызов | `INFO` |
|
| SLOG-29.1 | успешный событийный вызов | `INFO` |
|
||||||
| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
|
| SLOG-29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
|
||||||
| R29.3 | попытка не удалась, делается retry | `WARN` |
|
| SLOG-29.3 | попытка не удалась, делается retry | `WARN` |
|
||||||
| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
|
| SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
|
||||||
|
|
||||||
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
|
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
|
||||||
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
|
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
|
||||||
уровень непригодным для главного вопроса «зависимость доступна?».
|
уровень непригодным для главного вопроса «зависимость доступна?».
|
||||||
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
|
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
|
||||||
разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение
|
разбираться владельцу. Различение SLOG-29.1 и SLOG-29.2 — то же самое разделение
|
||||||
событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет
|
событийного и рутинного, что в SLOG-11: поллинг внешнего сервиса зашумляет
|
||||||
аудит так же, как любой другой.
|
аудит так же, как любой другой.
|
||||||
|
|
||||||
## Два цикла повтора — не путать
|
## Два цикла повтора — не путать
|
||||||
@@ -401,8 +413,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
|||||||
уровень доменной записи об исходе тика.
|
уровень доменной записи об исходе тика.
|
||||||
|
|
||||||
```
|
```
|
||||||
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4)
|
WHEN зависимость недоступна и ретраи вызова исчерпаны
|
||||||
AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27)
|
→ ext-запись `ERROR` (SLOG-29.4)
|
||||||
|
AND тик фонового цикла упал по той же причине
|
||||||
|
→ доменная запись `WARN` (SLOG-27)
|
||||||
```
|
```
|
||||||
|
|
||||||
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
|
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
|
||||||
@@ -411,7 +425,7 @@ AND тик фонового цикла упал по той же причине
|
|||||||
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
|
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
|
||||||
подавлением повторов в самом клиенте, а не переклассификацией уровня.
|
подавлением повторов в самом клиенте, а не переклассификацией уровня.
|
||||||
|
|
||||||
### R30. Ответ 4xx — успех на транспортном уровне
|
### SLOG-30. Ответ 4xx — успех на транспортном уровне
|
||||||
|
|
||||||
**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов
|
**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов
|
||||||
(`ext.status_code` записан); решение «это ошибка» принимает доменный
|
(`ext.status_code` записан); решение «это ошибка» принимает доменный
|
||||||
@@ -426,38 +440,38 @@ AND тик фонового цикла упал по той же причине
|
|||||||
|
|
||||||
## HTTP и healthcheck
|
## HTTP и healthcheck
|
||||||
|
|
||||||
### R31. Входящий запрос — `INFO` независимо от кода ответа
|
### SLOG-31. Входящий запрос — `INFO` независимо от кода ответа
|
||||||
|
|
||||||
**ДОЛЖЕН.** Поля по R16.1; 4xx остаётся `INFO`-записью доступа.
|
**ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа.
|
||||||
|
|
||||||
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и
|
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и
|
||||||
когда приходил», и ценность у неё одинаковая при любом коде ответа.
|
когда приходил», и ценность у неё одинаковая при любом коде ответа.
|
||||||
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
|
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
|
||||||
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
|
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
|
||||||
(R25) — она и адресована по-другому.
|
(SLOG-25) — она и адресована по-другому.
|
||||||
|
|
||||||
### R32. Для корреляции запроса допустим `request_id`
|
### SLOG-32. Для корреляции запроса допустим `request_id`
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
|
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
|
||||||
|
|
||||||
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
|
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
|
||||||
правило R18. Не запрещает: R18 отказывается от случайного ключа там, где
|
правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где
|
||||||
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
|
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
|
||||||
сущности нет — связать его записи между собой больше нечем.
|
сущности нет — связать его записи между собой больше нечем.
|
||||||
|
|
||||||
### R33. Healthcheck, liveness, readiness — `DEBUG`
|
### SLOG-33. Healthcheck, liveness, readiness — `DEBUG`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
|
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
|
||||||
|
|
||||||
**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают
|
**Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
|
||||||
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
|
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
|
||||||
аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в
|
аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в
|
||||||
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
|
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
|
||||||
доступной при отладке.
|
доступной при отладке.
|
||||||
|
|
||||||
## Безопасность: что не логируем
|
## Безопасность: что не логируем
|
||||||
|
|
||||||
### R34. Секреты не логируются
|
### SLOG-34. Секреты не логируются
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий,
|
**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий,
|
||||||
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
|
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
|
||||||
@@ -468,17 +482,17 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
|||||||
с момента записи, а не с момента, когда это заметили, и вычистить его задним
|
с момента записи, а не с момента, когда это заметили, и вычистить его задним
|
||||||
числом из уже собранных копий нельзя.
|
числом из уже собранных копий нельзя.
|
||||||
|
|
||||||
### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
|
### SLOG-35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
|
||||||
|
|
||||||
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
|
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
|
||||||
`DEBUG`, с вычисткой секретов и обрезкой по длине.
|
`DEBUG`, с вычисткой секретов и обрезкой по длине.
|
||||||
|
|
||||||
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав
|
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав
|
||||||
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
|
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
|
||||||
выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией;
|
выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией;
|
||||||
обрезка не даёт одной записи вытеснить весь остальной лог за период.
|
обрезка не даёт одной записи вытеснить весь остальной лог за период.
|
||||||
|
|
||||||
### R36. При сомнении логируется факт, а не значение
|
### SLOG-36. При сомнении логируется факт, а не значение
|
||||||
|
|
||||||
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
|
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
|
||||||
|
|
||||||
@@ -488,7 +502,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
|||||||
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
|
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
|
||||||
никто не придёт.
|
никто не придёт.
|
||||||
|
|
||||||
### R37. `*url.Error` санитизируется на границе клиента
|
### SLOG-37. `*url.Error` санитизируется на границе клиента
|
||||||
|
|
||||||
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
|
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
|
||||||
обёртки — раньше трансляции в доменную (`lang/go/errors.md`).
|
обёртки — раньше трансляции в доменную (`lang/go/errors.md`).
|
||||||
@@ -502,12 +516,12 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
|||||||
причину сохраняется); альтернатива с редактированием URL сохранила бы
|
причину сохраняется); альтернатива с редактированием URL сохранила бы
|
||||||
структуру, но сложнее.
|
структуру, но сложнее.
|
||||||
|
|
||||||
### R38. Секрет не кладётся в URL, если у API есть заголовок
|
### SLOG-38. Секрет не кладётся в URL, если у API есть заголовок
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
|
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
|
||||||
способа нет.
|
способа нет.
|
||||||
|
|
||||||
**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и
|
**Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
|
||||||
в любую запись, куда URL попал целиком, — то есть обязывает помнить про
|
в любую запись, куда URL попал целиком, — то есть обязывает помнить про
|
||||||
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
|
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
|
||||||
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
|
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
|
||||||
@@ -517,7 +531,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
|||||||
|
|
||||||
## Куда пишем
|
## Куда пишем
|
||||||
|
|
||||||
### R39. Логи идут в `stdout` одним потоком
|
### SLOG-39. Логи идут в `stdout` одним потоком
|
||||||
|
|
||||||
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
|
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
|
||||||
не маршрутизируем.
|
не маршрутизируем.
|
||||||
@@ -528,23 +542,23 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
|||||||
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
|
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
|
||||||
теряет его ровно там, где важен ход событий.
|
теряет его ровно там, где важен ход событий.
|
||||||
|
|
||||||
### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
|
### SLOG-40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
|
||||||
|
|
||||||
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
|
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
|
||||||
|
|
||||||
**Почему.** Уровень — единственный регулятор объёма, доступный без
|
**Почему.** Уровень — единственный регулятор объёма, доступный без
|
||||||
пересборки; если `DEBUG` в проде включается только правкой кода, его не
|
пересборки; если `DEBUG` в проде включается только правкой кода, его не
|
||||||
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
|
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
|
||||||
что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2).
|
что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2).
|
||||||
|
|
||||||
## Связано
|
## Связано
|
||||||
|
|
||||||
- `arch/time.md` — точность и зона меток времени фиксируются на носитель.
|
- `arch/time.md` — точность и зона меток времени фиксируются на носитель.
|
||||||
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (R3).
|
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (SLOG-3).
|
||||||
- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно
|
- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно
|
||||||
санитизации (R37).
|
санитизации (SLOG-37).
|
||||||
- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы,
|
- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы,
|
||||||
на которых держится корреляция (R18).
|
на которых держится корреляция (SLOG-18).
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
prefix: GTIM
|
||||||
extends: arch/time.md
|
extends: arch/time.md
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -6,11 +7,11 @@ extends: arch/time.md
|
|||||||
|
|
||||||
Как требования `arch/time.md` выполняются в Go-коде: откуда берётся
|
Как требования `arch/time.md` выполняются в Go-коде: откуда берётся
|
||||||
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами.
|
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами.
|
||||||
Форма записи — `common/language.md`.
|
Форма записи — `LANGUAGE.md`.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. «Сейчас» берётся у слоя хранилища
|
### GTIM-1. «Сейчас» берётся у слоя хранилища
|
||||||
|
|
||||||
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
|
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
|
||||||
`time.Now().UTC()`, а не из `time.Now()` по коду.
|
`time.Now().UTC()`, а не из `time.Now()` по коду.
|
||||||
@@ -24,28 +25,28 @@ extends: arch/time.md
|
|||||||
придётся превратить в переменную или поле, если однажды понадобится
|
придётся превратить в переменную или поле, если однажды понадобится
|
||||||
подменять часы, но само по себе оно подмены не даёт.
|
подменять часы, но само по себе оно подмены не даёт.
|
||||||
|
|
||||||
### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
|
### GTIM-2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
|
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
|
||||||
получить строку времени и прочитать её обратно.
|
получить строку времени и прочитать её обратно.
|
||||||
|
|
||||||
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
|
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
|
||||||
свойство каждой отдельной строки кода. Фиксированная ширина (R4) и
|
свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
|
||||||
взаимная обратимость записи и чтения держатся ровно до первого второго
|
взаимная обратимость записи и чтения держатся ровно до первого второго
|
||||||
layout — а расхождение проявится не на записи, а при сравнении значений,
|
layout — а расхождение проявится не на записи, а при сравнении значений,
|
||||||
записанных разными местами.
|
записанных разными местами.
|
||||||
|
|
||||||
### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
|
### GTIM-3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
|
||||||
|
|
||||||
**ДОЛЖЕН.** Запрет механизируется `forbidigo`; исключений ровно два, и оба
|
**ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений
|
||||||
прописаны явно:
|
ровно два, и оба прописаны явно:
|
||||||
|
|
||||||
| № | Исключение | Почему оно не покрывается R1 |
|
| № | Исключение | Почему оно не покрывается GTIM-1 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
|
| GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
|
||||||
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) |
|
| GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
|
||||||
|
|
||||||
**Почему.** R1 без механической проверки держится на внимании, а
|
**Почему.** GTIM-1 без механической проверки держится на внимании, а
|
||||||
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
|
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
|
||||||
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
|
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
|
||||||
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
|
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
|
||||||
@@ -53,7 +54,23 @@ layout — а расхождение проявится не на записи,
|
|||||||
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
|
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
|
||||||
сама себе.
|
сама себе.
|
||||||
|
|
||||||
### R4. В БД время хранится с секундной точностью, ширина 20 символов
|
### GTIM-13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Исключение из GTIM-3 оформляется как
|
||||||
|
`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
|
||||||
|
конфигурации линтера для него не заводятся.
|
||||||
|
|
||||||
|
**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует
|
||||||
|
исключение путём к файлу, отвязывается при переносе кода и продолжает
|
||||||
|
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
|
||||||
|
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
|
||||||
|
та исчерпываемость, которой требует GTIM-3, проверяется одной командой. Голый
|
||||||
|
`//nolint` без имени правила глушит на строке все проверки сразу, а без
|
||||||
|
причины неотличим от заглушенного дефекта; обе деградации штатно ловит
|
||||||
|
`nolintlint` (`require-specific`, `require-explanation`) — стандартный
|
||||||
|
способ дисциплинировать директивы в golangci-lint.
|
||||||
|
|
||||||
|
### GTIM-4. В БД время хранится с секундной точностью, ширина 20 символов
|
||||||
|
|
||||||
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
|
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
|
||||||
|
|
||||||
@@ -67,16 +84,16 @@ layout — а расхождение проявится не на записи,
|
|||||||
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
|
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
|
||||||
поэтому `Format` их не выведет.
|
поэтому `Format` их не выведет.
|
||||||
|
|
||||||
### R5. `time.RFC3339Nano` не используется
|
### GTIM-5. `time.RFC3339Nano` не используется
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
|
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
|
||||||
|
|
||||||
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
|
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
|
||||||
от значения: соседние записи получают разную ширину, и свойство, на котором
|
от значения: соседние записи получают разную ширину, и свойство, на котором
|
||||||
держится R4, исчезает незаметно. Проверка «формат корректен» при этом
|
держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
|
||||||
проходит — отказывает только порядок.
|
проходит — отказывает только порядок.
|
||||||
|
|
||||||
### R6. Чужой вход нормализуется явно
|
### GTIM-6. Чужой вход нормализуется явно
|
||||||
|
|
||||||
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
|
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
|
||||||
к каноническому виду явно, а не считается каноническим по факту успешного
|
к каноническому виду явно, а не считается каноническим по факту успешного
|
||||||
@@ -85,19 +102,21 @@ layout — а расхождение проявится не на записи,
|
|||||||
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
|
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
|
||||||
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
|
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
|
||||||
**писатель**, а не читатель; пока писатель один, этого достаточно, но
|
**писатель**, а не читатель; пока писатель один, этого достаточно, но
|
||||||
значение из чужой системы, положенное в базу как пришло, нарушает R4 и
|
значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
|
||||||
обнаруживается не на записи, а на первой сортировке.
|
обнаруживается не на записи, а на первой сортировке. Само решение
|
||||||
|
«нормализовать, а не отклонять» — базовое (`TIME-13`); здесь —
|
||||||
|
Go-механика, из-за которой его легко нарушить незаметно.
|
||||||
|
|
||||||
### R7. В драйвер передаётся строка, а не `time.Time`
|
### GTIM-7. В драйвер передаётся строка, а не `time.Time`
|
||||||
|
|
||||||
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
|
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
|
||||||
|
|
||||||
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
|
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
|
||||||
драйверу: появляется вторая точка формата вне `FormatTime` (R2), с
|
драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
|
||||||
собственным layout, который меняется вместе с версией драйвера, а не вместе
|
собственным layout, который меняется вместе с версией драйвера, а не вместе
|
||||||
с конвенцией.
|
с конвенцией.
|
||||||
|
|
||||||
### R8. Время в логах приводится к UTC через `ReplaceAttr`
|
### GTIM-8. Время в логах приводится к UTC через `ReplaceAttr`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
|
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
|
||||||
|
|
||||||
@@ -116,17 +135,17 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
|||||||
неверная зона выглядит как совершенно валидное время, а записи из разных
|
неверная зона выглядит как совершенно валидное время, а записи из разных
|
||||||
мест перестают складываться в одну хронологию с метками хранилища.
|
мест перестают складываться в одну хронологию с метками хранилища.
|
||||||
|
|
||||||
### R9. Точность времени в логах отличается от точности в БД
|
### GTIM-9. Точность времени в логах отличается от точности в БД
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
|
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
|
||||||
приводится к секундной точности R4.
|
приводится к секундной точности GTIM-4.
|
||||||
|
|
||||||
**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование
|
**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
|
||||||
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
|
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
|
||||||
такая же фиксированная ширина, и свойство, ради которого R4 существует, не
|
такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
|
||||||
нарушено. Общее у лога и базы одно — зона (R8).
|
нарушено. Общее у лога и базы одно — зона (GTIM-8).
|
||||||
|
|
||||||
### R10. Обёртка измерения длительности берёт `time.Now()` напрямую
|
### GTIM-10. Обёртка измерения длительности берёт `time.Now()` напрямую
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с
|
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с
|
||||||
локальным `//nolint`.
|
локальным `//nolint`.
|
||||||
@@ -135,10 +154,10 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
|||||||
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
|
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
|
||||||
меткам, зависит от подводки часов: перевод назад даёт отрицательную
|
меткам, зависит от подводки часов: перевод назад даёт отрицательную
|
||||||
длительность, скачок вперёд — выброс в измерениях, и оба случая
|
длительность, скачок вперёд — выброс в измерениях, и оба случая
|
||||||
невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается
|
невоспроизводимы. Разрешение записано явно, иначе исключение GTIM-3.2 читается
|
||||||
как недосмотр и его «чинят».
|
как недосмотр и его «чинят».
|
||||||
|
|
||||||
### R11. `time/tzdata` импортируется в `main`
|
### GTIM-11. `time/tzdata` импортируется в `main`
|
||||||
|
|
||||||
**ДОЛЖЕН.** База зон вшивается в бинарь.
|
**ДОЛЖЕН.** База зон вшивается в бинарь.
|
||||||
|
|
||||||
@@ -148,7 +167,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
|||||||
`main` держит это решение в одном видимом месте, а не в случайном пакете,
|
`main` держит это решение в одном видимом месте, а не в случайном пакете,
|
||||||
откуда его удаляют при чистке зависимостей.
|
откуда его удаляют при чистке зависимостей.
|
||||||
|
|
||||||
### R12. Зона отображения применяется только в UI
|
### GTIM-12. Зона отображения применяется только в UI
|
||||||
|
|
||||||
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
|
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
|
||||||
представления, но не в хранимых значениях и не в вычислениях.
|
представления, но не в хранимых значениях и не в вычислениях.
|
||||||
@@ -166,6 +185,6 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
|||||||
|
|
||||||
## Связано
|
## Связано
|
||||||
|
|
||||||
- `arch/time.md` — базовая конвенция: UTC как формат хранения, явная зона в
|
- `arch/time.md` — базовая конвенция: UTC как формат хранения, нормализация
|
||||||
календарных вычислениях.
|
чужого входа, явная зона в календарных вычислениях.
|
||||||
- `lang/go/config.md` — валидация зоны отображения загрузчиком конфига.
|
- `lang/go/config.md` — валидация зоны отображения загрузчиком конфига.
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
|
prefix: ANSD
|
||||||
extends: arch/app-directories.md
|
extends: arch/app-directories.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# Категории директорий: реализация в Ansible
|
# Категории директорий: реализация в Ansible
|
||||||
|
|
||||||
Как категории из `arch/app-directories.md` раскладываются на сервере
|
Как категории из `arch/app-directories.md` раскладываются на сервере
|
||||||
плейбуком. Форма записи — `common/language.md`.
|
плейбуком. Форма записи — `LANGUAGE.md`.
|
||||||
|
|
||||||
## Область действия
|
## Область действия
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ extends: arch/app-directories.md
|
|||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### R1. Каждая директория объявлена переменной `*_dir`
|
### ANSD-1. Каждая директория объявлена переменной `*_dir`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри
|
**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри
|
||||||
`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на
|
`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на
|
||||||
@@ -24,11 +25,11 @@ extends: arch/app-directories.md
|
|||||||
`uploads_dir`, `dumps_dir`).
|
`uploads_dir`, `dumps_dir`).
|
||||||
|
|
||||||
**Почему.** Переменная — единственная ссылка, которую разделяют задача
|
**Почему.** Переменная — единственная ссылка, которую разделяют задача
|
||||||
создания директории и список бэкапа (R4). Литерал пути в одном из этих мест
|
создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест
|
||||||
означает, что переименование директории молча разойдётся с бэкапом, и
|
означает, что переименование директории молча разойдётся с бэкапом, и
|
||||||
обнаружится это при восстановлении.
|
обнаружится это при восстановлении.
|
||||||
|
|
||||||
### R2. Директории создаются одной задачей циклом по списку
|
### ANSD-2. Директории создаются одной задачей циклом по списку
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Список директорий в единственной задаче создания.
|
**СЛЕДУЕТ.** Список директорий в единственной задаче создания.
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ extends: arch/app-directories.md
|
|||||||
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
|
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
|
||||||
разборе места на диске.
|
разборе места на диске.
|
||||||
|
|
||||||
### R3. Владелец директорий — пользователь, от имени которого работает приложение
|
### ANSD-3. Владелец директорий — пользователь, от имени которого работает приложение
|
||||||
|
|
||||||
**ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение
|
**ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение
|
||||||
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
|
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
|
||||||
@@ -55,18 +56,18 @@ extends: arch/app-directories.md
|
|||||||
<!-- local:модель-владельца -->
|
<!-- local:модель-владельца -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
### R4. Список бэкапа собирается из тех же переменных
|
### ANSD-4. Список бэкапа собирается из тех же переменных
|
||||||
|
|
||||||
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
|
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
|
||||||
которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути.
|
которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути.
|
||||||
|
|
||||||
**Почему.** Правило вывода списка механическое (R5), но применяет его
|
**Почему.** Правило вывода списка механическое (ANSD-5), но применяет его
|
||||||
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
|
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
|
||||||
класс ошибок невозможным: переименовал директорию — переименовалось в
|
класс ошибок невозможным: переименовал директорию — переименовалось в
|
||||||
обоих местах. Независимо набранный список расходится тихо и проявляется в
|
обоих местах. Независимо набранный список расходится тихо и проявляется в
|
||||||
единственный момент, когда это уже неисправимо.
|
единственный момент, когда это уже неисправимо.
|
||||||
|
|
||||||
### R5. В список бэкапа идут только данные
|
### ANSD-5. В список бэкапа идут только данные
|
||||||
|
|
||||||
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
|
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
|
||||||
списке; конфигурация и кеш — нет.
|
списке; конфигурация и кеш — нет.
|
||||||
@@ -75,7 +76,7 @@ extends: arch/app-directories.md
|
|||||||
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
|
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
|
||||||
облако, и источником истины для секретов остаётся vault, а не снапшот.
|
облако, и источником истины для секретов остаётся vault, а не снапшот.
|
||||||
|
|
||||||
### R6. Конфигурация монтируется только на чтение
|
### ANSD-6. Конфигурация монтируется только на чтение
|
||||||
|
|
||||||
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
|
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ extends: arch/app-directories.md
|
|||||||
незаметно. Приложение, которому запись в конфиг нужна по устройству,
|
незаметно. Приложение, которому запись в конфиг нужна по устройству,
|
||||||
монтируется на запись — это отступление, и оно записывается.
|
монтируется на запись — это отступление, и оно записывается.
|
||||||
|
|
||||||
### R7. `docker-compose.yml` лежит в корне `base_dir`
|
### ANSD-7. `docker-compose.yml` лежит в корне `base_dir`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Файл не переносится во вложенную директорию.
|
**ДОЛЖЕН.** Файл не переносится во вложенную директорию.
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ extends: arch/app-directories.md
|
|||||||
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
|
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
|
||||||
место.
|
место.
|
||||||
|
|
||||||
### R8. Секреты рендерятся в файл конфигурации
|
### ANSD-8. Секреты рендерятся в файл конфигурации
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
|
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
|
||||||
принадлежащий пользователю приложения.
|
принадлежащий пользователю приложения.
|
||||||
@@ -104,14 +105,14 @@ extends: arch/app-directories.md
|
|||||||
довода, по которым базовая конвенция конфигурации выбирает файл вместо
|
довода, по которым базовая конвенция конфигурации выбирает файл вместо
|
||||||
окружения.
|
окружения.
|
||||||
|
|
||||||
### R9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
|
### ANSD-9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
|
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
|
||||||
|
|
||||||
**Почему.** Явное разрешение нужно, чтобы R8 не читался как запрет на
|
**Почему.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на
|
||||||
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
|
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
|
||||||
контейнера и в compose-файл на диске. Приложение, научившееся читать
|
контейнера и в compose-файл на диске. Приложение, научившееся читать
|
||||||
секреты из файла, переводится на R8 при ближайшем касании.
|
секреты из файла, переводится на ANSD-8 при ближайшем касании.
|
||||||
|
|
||||||
<!-- local:отступления -->
|
<!-- local:отступления -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
---
|
||||||
|
prefix: HTMX
|
||||||
|
---
|
||||||
|
|
||||||
# Веб-UI на htmx
|
# Веб-UI на htmx
|
||||||
|
|
||||||
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
|
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
|
||||||
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
|
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
|
||||||
показывает и какие действия поддерживает — в спеках, не здесь. Форма записи
|
показывает и какие действия поддерживает — в спеках, не здесь. Форма записи
|
||||||
— `common/language.md`.
|
— `LANGUAGE.md`.
|
||||||
|
|
||||||
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
|
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
|
||||||
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
|
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
|
||||||
@@ -18,7 +22,7 @@
|
|||||||
|
|
||||||
## Стек и границы
|
## Стек и границы
|
||||||
|
|
||||||
### R1. Стек: роутер, серверные шаблоны, htmx
|
### HTMX-1. Стек: роутер, серверные шаблоны, htmx
|
||||||
|
|
||||||
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
|
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
|
||||||
без Node и бандлера, без реактивного фреймворка.
|
без Node и бандлера, без реактивного фреймворка.
|
||||||
@@ -26,12 +30,12 @@
|
|||||||
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
|
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
|
||||||
артефакт, который расходится с исходником; приложению, где разметку целиком
|
артефакт, который расходится с исходником; приложению, где разметку целиком
|
||||||
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
|
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
|
||||||
модель состояния рядом с серверной (R2), и дальше на каждом экране
|
модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
|
||||||
приходится решать, какая из них главная. Сам htmx — вендорный ассет и
|
приходится решать, какая из них главная. Сам htmx — вендорный ассет и
|
||||||
живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму
|
живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к
|
||||||
приложения аптайм чужого хоста.
|
аптайму приложения аптайм чужого хоста.
|
||||||
|
|
||||||
### R2. Клиент не пересчитывает доменное состояние
|
### HTMX-2. Клиент не пересчитывает доменное состояние
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
|
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
|
||||||
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
|
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
|
||||||
@@ -40,24 +44,24 @@
|
|||||||
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
|
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
|
||||||
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
|
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
|
||||||
базе другое». Вдобавок клиентский пересчёт по определению не работает в
|
базе другое». Вдобавок клиентский пересчёт по определению не работает в
|
||||||
деградированном режиме (R11, R12) — значит, серверную версию того же
|
деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
|
||||||
вычисления всё равно придётся держать.
|
вычисления всё равно придётся держать.
|
||||||
|
|
||||||
### R3. Реактивный слой вводится отдельным решением
|
### HTMX-3. Реактивный слой вводится отдельным решением
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
|
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
|
||||||
только когда есть виджет, которому он действительно нужен, и отдельным
|
только когда есть виджет, которому он действительно нужен, и отдельным
|
||||||
решением.
|
решением.
|
||||||
|
|
||||||
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего
|
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего
|
||||||
списка, немедленно доступен всему остальному коду — и граница R1/R2
|
списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
|
||||||
перестаёт держаться сама собой. Отдельное решение — единственный момент,
|
перестаёт держаться сама собой. Отдельное решение — единственный момент,
|
||||||
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
|
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
|
||||||
каждом экране есть выбор между двумя моделями состояния.
|
каждом экране есть выбор между двумя моделями состояния.
|
||||||
|
|
||||||
## Единый источник разметки
|
## Единый источник разметки
|
||||||
|
|
||||||
### R4. Партиал = страница = фрагмент
|
### HTMX-4. Партиал = страница = фрагмент
|
||||||
|
|
||||||
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
|
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
|
||||||
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
|
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
|
||||||
@@ -68,7 +72,7 @@
|
|||||||
же региона. Заметно это становится только на глаз и только тому, кто открыл
|
же региона. Заметно это становится только на глаз и только тому, кто открыл
|
||||||
оба пути подряд.
|
оба пути подряд.
|
||||||
|
|
||||||
### R5. Корень партиала — элемент с целевым `id`
|
### HTMX-5. Корень партиала — элемент с целевым `id`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
|
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
|
||||||
регион, и ответный фрагмент несёт тот же `id`.
|
регион, и ответный фрагмент несёт тот же `id`.
|
||||||
@@ -79,19 +83,19 @@
|
|||||||
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
|
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
|
||||||
логе.
|
логе.
|
||||||
|
|
||||||
### R6. Сборку view делает общая функция
|
### HTMX-6. Сборку view делает общая функция
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
|
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
|
||||||
htmx-ветка.
|
htmx-ветка.
|
||||||
|
|
||||||
**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не
|
**Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
|
||||||
одинаковые данные: скопированная сборка view расходится по набору полей, и
|
одинаковые данные: скопированная сборка view расходится по набору полей, и
|
||||||
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
|
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
|
||||||
класс расхождений, который R4 закрывает для разметки.
|
класс расхождений, который HTMX-4 закрывает для разметки.
|
||||||
|
|
||||||
## Обработчик действия
|
## Обработчик действия
|
||||||
|
|
||||||
### R7. Доменный вызов одинаков для htmx и обычного запроса
|
### HTMX-7. Доменный вызов одинаков для htmx и обычного запроса
|
||||||
|
|
||||||
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
|
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
|
||||||
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
|
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
|
||||||
@@ -99,8 +103,8 @@ htmx-ветка.
|
|||||||
|
|
||||||
| № | Запрос | Ответ |
|
| № | Запрос | Ответ |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию |
|
| HTMX-7.1 | `HX-Request: true` | фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию |
|
||||||
| R7.2 | обычный | PRG-редирект (303) |
|
| HTMX-7.2 | обычный | PRG-редирект (303) |
|
||||||
|
|
||||||
```go
|
```go
|
||||||
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
|
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
|
||||||
@@ -119,12 +123,12 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
|
|
||||||
**Почему.** Ветвление до вызова даёт две реализации одного действия, и
|
**Почему.** Ветвление до вызова даёт две реализации одного действия, и
|
||||||
дальше дефект воспроизводится только на одной поверхности — причём
|
дальше дефект воспроизводится только на одной поверхности — причём
|
||||||
деградированный путь (R11) открывают реже, то есть чинить будут не тот.
|
деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
|
||||||
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
|
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
|
||||||
целиком: view, собранный из аргументов запроса, покажет намерение, а не
|
целиком: view, собранный из аргументов запроса, покажет намерение, а не
|
||||||
результат.
|
результат.
|
||||||
|
|
||||||
### R8. Шаблон рендерится в буфер, потом в ответ
|
### HTMX-8. Шаблон рендерится в буфер, потом в ответ
|
||||||
|
|
||||||
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
|
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
|
||||||
буфер пишется в ответ.
|
буфер пишется в ответ.
|
||||||
@@ -136,30 +140,30 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
|
|
||||||
## Одно действие — два региона
|
## Одно действие — два региона
|
||||||
|
|
||||||
### R9. Второй регион едет тем же ответом через `hx-swap-oob`
|
### HTMX-9. Второй регион едет тем же ответом через `hx-swap-oob`
|
||||||
|
|
||||||
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
|
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
|
||||||
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
|
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
|
||||||
партиалом с тем же `id`, что и на странице (R4, R5).
|
партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
|
||||||
|
|
||||||
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают
|
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают
|
||||||
состояние в разные моменты и приезжают в произвольном порядке, поэтому
|
состояние в разные моменты и приезжают в произвольном порядке, поэтому
|
||||||
панель действий может отразить состояние до действия. Плюс лишний
|
панель действий может отразить состояние до действия. Плюс лишний
|
||||||
раунд-трип на каждое действие.
|
раунд-трип на каждое действие.
|
||||||
|
|
||||||
### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
|
### HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
|
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
|
||||||
регион меняется не на каждое действие.
|
регион меняется не на каждое действие.
|
||||||
|
|
||||||
**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого
|
**Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
|
||||||
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
|
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
|
||||||
одинаковую разметку на каждое действие и связывает два шаблона там, где
|
одинаковую разметку на каждое действие и связывает два шаблона там, где
|
||||||
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
|
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
|
||||||
|
|
||||||
## Graceful degradation
|
## Graceful degradation
|
||||||
|
|
||||||
### R11. Форма действия работает без JS
|
### HTMX-11. Форма действия работает без JS
|
||||||
|
|
||||||
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
|
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
|
||||||
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
|
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
|
||||||
@@ -170,24 +174,24 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
ничего, молча. Тот же `action` — единственное, что делает действие
|
ничего, молча. Тот же `action` — единственное, что делает действие
|
||||||
проверяемым без браузера с JS.
|
проверяемым без браузера с JS.
|
||||||
|
|
||||||
### R12. Фильтр, поиск и пагинация — серверные
|
### HTMX-12. Фильтр, поиск и пагинация — серверные
|
||||||
|
|
||||||
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
|
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
|
||||||
клиентской фильтрации загруженной разметки нет.
|
клиентской фильтрации загруженной разметки нет.
|
||||||
|
|
||||||
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
|
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
|
||||||
фильтр отвечает по неполным данным и делает это молча — результат выглядит
|
фильтр отвечает по неполным данным и делает это молча — результат выглядит
|
||||||
валидным. Вдобавок состояние отбора в query переживает своп (R25) и
|
валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
|
||||||
перезагрузку, его можно послать ссылкой и увидеть в логе.
|
перезагрузку, его можно послать ссылкой и увидеть в логе.
|
||||||
|
|
||||||
### R13. Область обязательной деградации
|
### HTMX-13. Область обязательной деградации
|
||||||
|
|
||||||
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
|
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
|
||||||
|
|
||||||
| № | Поверхность | Поведение без JS |
|
| № | Поверхность | Поведение без JS |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R13.1 | действия и навигация | работают полностью (R11, R12) |
|
| HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
|
||||||
| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
|
| HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
|
||||||
|
|
||||||
**Почему.** Без явной границы правило деградации читается как запрет на
|
**Почему.** Без явной границы правило деградации читается как запрет на
|
||||||
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
|
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
|
||||||
@@ -197,33 +201,52 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
|
|
||||||
## Ошибки на htmx-пути
|
## Ошибки на htmx-пути
|
||||||
|
|
||||||
### R14. Ошибка действия на htmx-пути — 200 с фрагментом
|
### HTMX-14. Ошибка действия на htmx-пути — 200 с фрагментом
|
||||||
|
|
||||||
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 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`), но любая
|
||||||
`htmx:responseError`), но любая настройка — свой JS-конфиг на клиенте, и
|
такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
|
||||||
платится она из R1 и R2. Для REST API и не-JS редиректа с `?err=` статус
|
Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и
|
||||||
по-прежнему используется: там его кто-то читает.
|
её решает глобальный слушатель (HTMX-34). Для REST API и не-JS редиректа с
|
||||||
|
`?err=` статус по-прежнему используется: там его кто-то читает.
|
||||||
|
|
||||||
Цена решения: в логе доступа провалившееся действие выглядит как `200`.
|
Цена решения: в логе доступа провалившееся действие выглядит как `200`.
|
||||||
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`),
|
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`),
|
||||||
а не по коду ответа.
|
а не по коду ответа.
|
||||||
|
|
||||||
### R15. Наружу идёт сообщение публичного канала
|
### HTMX-34. Сбой без ответа-фрагмента показывается глобальным слушателем
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и
|
||||||
|
`htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп
|
||||||
|
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
|
||||||
|
`response-targets`) не настраивается.
|
||||||
|
|
||||||
|
**Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
|
||||||
|
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
|
||||||
|
свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
|
||||||
|
и пользователь повторяет действие, которое могло уже примениться. Слушатель
|
||||||
|
— несколько строк без доменного состояния, то есть внутри границы HTMX-2, и он
|
||||||
|
не спорит с HTMX-14: там настройки отвергнуты как замена фрагменту, который
|
||||||
|
обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп
|
||||||
|
тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт
|
||||||
|
целевого `id`, и после первого же такого свопа регион перестаёт находиться
|
||||||
|
(HTMX-5).
|
||||||
|
|
||||||
|
### HTMX-15. Наружу идёт сообщение публичного канала
|
||||||
|
|
||||||
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам
|
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам
|
||||||
`lang/go/errors.md`; `err.Error()` в разметку не рендерится.
|
`lang/go/errors.md`; `err.Error()` в разметку не рендерится.
|
||||||
|
|
||||||
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
|
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
|
||||||
легче всего забыть, что это тот же публичный канал, что и страница:
|
легче всего забыть, что это тот же публичный канал, что и страница:
|
||||||
разметка уезжает в браузер пользователя целиком. Статус 200 (R14)
|
разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
|
||||||
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
|
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
|
||||||
|
|
||||||
### R16. Сообщение об ошибке — в отдельном поле view
|
### HTMX-16. Сообщение об ошибке — в отдельном поле view
|
||||||
|
|
||||||
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
|
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
|
||||||
сообщение не переиспользуются.
|
сообщение не переиспользуются.
|
||||||
@@ -232,9 +255,9 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
|
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
|
||||||
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
|
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
|
||||||
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
|
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
|
||||||
требует R17.
|
требует HTMX-17.
|
||||||
|
|
||||||
### R17. При ошибке активное состояние не меняется
|
### HTMX-17. При ошибке активное состояние не меняется
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
|
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
|
||||||
прежний выбор плюс сообщение.
|
прежний выбор плюс сообщение.
|
||||||
@@ -257,7 +280,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
</div>{{end}}
|
</div>{{end}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### R18. Поллер самозавершается
|
### HTMX-18. Поллер самозавершается
|
||||||
|
|
||||||
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
|
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
|
||||||
`hx-*`-атрибутов.
|
`hx-*`-атрибутов.
|
||||||
@@ -268,10 +291,10 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
это единственный канал, которым сервер управляет поллером.
|
это единственный канал, которым сервер управляет поллером.
|
||||||
|
|
||||||
Встроенная альтернатива — ответ со статусом 286 — не используется: она не
|
Встроенная альтернатива — ответ со статусом 286 — не используется: она не
|
||||||
совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом
|
совместима с HTMX-4, ведь свежезагруженная страница рендерится тем же партиалом
|
||||||
и тоже без поллера.
|
и тоже без поллера.
|
||||||
|
|
||||||
### R19. Условие живости ведёт собственное состояние приложения
|
### HTMX-19. Условие живости ведёт собственное состояние приложения
|
||||||
|
|
||||||
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
|
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
|
||||||
приложение, а не по ответу внешнего сервиса.
|
приложение, а не по ответу внешнего сервиса.
|
||||||
@@ -281,18 +304,18 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
останавливается никогда. Приложение — единственный участник, который знает
|
останавливается никогда. Приложение — единственный участник, который знает
|
||||||
про операцию всё и может ответить на каждом тике.
|
про операцию всё и может ответить на каждом тике.
|
||||||
|
|
||||||
### R20. Поллер свопит фрагмент целиком через `outerHTML`
|
### HTMX-20. Поллер свопит фрагмент целиком через `outerHTML`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
|
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
|
||||||
содержимое.
|
содержимое.
|
||||||
|
|
||||||
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
|
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
|
||||||
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
|
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
|
||||||
выключается (R18). Своп содержимого оставил бы старый узел с его таймером,
|
выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
|
||||||
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
|
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
|
||||||
при совпадении корневого `id` (R5).
|
при совпадении корневого `id` (HTMX-5).
|
||||||
|
|
||||||
### R21. Поллер не свопит контейнер с активными полями ввода
|
### HTMX-21. Поллер не свопит контейнер с активными полями ввода
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
|
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
|
||||||
редактировать нечего.
|
редактировать нечего.
|
||||||
@@ -302,7 +325,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
|
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
|
||||||
как «приложение стирает мой ввод».
|
как «приложение стирает мой ввод».
|
||||||
|
|
||||||
### R22. Браузер не ходит во внешний сервис напрямую
|
### HTMX-22. Браузер не ходит во внешний сервис напрямую
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
|
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
|
||||||
|
|
||||||
@@ -311,14 +334,14 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
|
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
|
||||||
серверным изменением.
|
серверным изменением.
|
||||||
|
|
||||||
### R23. Источник данных для тика
|
### HTMX-23. Источник данных для тика
|
||||||
|
|
||||||
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
|
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
|
||||||
|
|
||||||
| № | Что показывает тик | Откуда берёт |
|
| № | Что показывает тик | Откуда берёт |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
|
| HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
|
||||||
| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
|
| HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
|
||||||
|
|
||||||
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
|
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
|
||||||
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
|
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
|
||||||
@@ -329,7 +352,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
|
|||||||
собственного состояния той же цены нет: хранилище и так своё, а лишний слой
|
собственного состояния той же цены нет: хранилище и так своё, а лишний слой
|
||||||
кеша добавил бы только рассинхрон.
|
кеша добавил бы только рассинхрон.
|
||||||
|
|
||||||
### R24. Поллинг URL страницы вместо отдельного фрагмент-роута
|
### HTMX-24. Поллинг URL страницы вместо отдельного фрагмент-роута
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
|
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
|
||||||
на URL самой страницы, а нужный узел вырезается `hx-select`:
|
на URL самой страницы, а нужный узел вырезается `hx-select`:
|
||||||
@@ -342,24 +365,24 @@ hx-select="#item-main" hx-swap="outerHTML"
|
|||||||
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
|
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
|
||||||
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
|
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
|
||||||
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
|
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
|
||||||
копии разметки (R4).
|
копии разметки (HTMX-4).
|
||||||
|
|
||||||
Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот
|
Инвариант корневого `id` (HTMX-5) действует и здесь: `hx-select` выбирает тот
|
||||||
же узел, который свопится.
|
же узел, который свопится.
|
||||||
|
|
||||||
## Своп и выход со страницы
|
## Своп и выход со страницы
|
||||||
|
|
||||||
### R25. Действие не уводит со страницы, если предмет остаётся на ней
|
### HTMX-25. Действие не уводит со страницы, если предмет остаётся на ней
|
||||||
|
|
||||||
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
|
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
|
||||||
|
|
||||||
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
|
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
|
||||||
пагинацию — они в query (R12). Полная навигация ради изменения одного
|
пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
|
||||||
региона возвращает пользователя в начало списка и стоит перерисовки всей
|
региона возвращает пользователя в начало списка и стоит перерисовки всей
|
||||||
страницы. Не сохраняется при свопе только контекст внутри самого
|
страницы. Не сохраняется при свопе только контекст внутри самого
|
||||||
заменяемого поддерева — фокус, выделение, введённый текст (R21).
|
заменяемого поддерева — фокус, выделение, введённый текст (HTMX-21).
|
||||||
|
|
||||||
### R26. Выход со страницы — форма без `hx-*`
|
### HTMX-26. Выход со страницы — форма без `hx-*`
|
||||||
|
|
||||||
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
|
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
|
||||||
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
|
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
|
||||||
@@ -370,10 +393,10 @@ htmx-атрибутов при этом само работает маркеро
|
|||||||
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
|
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
|
||||||
сменить страницу, существующий только на htmx-пути.
|
сменить страницу, существующий только на htmx-пути.
|
||||||
|
|
||||||
### R27. Асинхронное действие свопит промежуточное состояние
|
### HTMX-27. Асинхронное действие свопит промежуточное состояние
|
||||||
|
|
||||||
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
|
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
|
||||||
промежуточное состояние, а итог догоняет самозавершающийся поллер (R18).
|
промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
|
||||||
|
|
||||||
**Почему.** Мнимый результат расходится с сервером до следующего тика, и
|
**Почему.** Мнимый результат расходится с сервером до следующего тика, и
|
||||||
всё это время пользователь принимает решения по несуществующему исходу —
|
всё это время пользователь принимает решения по несуществующему исходу —
|
||||||
@@ -382,7 +405,7 @@ htmx-атрибутов при этом само работает маркеро
|
|||||||
|
|
||||||
## Различение поверхности одного действия
|
## Различение поверхности одного действия
|
||||||
|
|
||||||
### R28. Поверхность различается скрытым полем формы
|
### HTMX-28. Поверхность различается скрытым полем формы
|
||||||
|
|
||||||
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
|
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
|
||||||
фрагментом, поверхность передаётся явным скрытым полем
|
фрагментом, поверхность передаётся явным скрытым полем
|
||||||
@@ -394,12 +417,28 @@ htmx-атрибутов при этом само работает маркеро
|
|||||||
действием, поэтому связь «эта страница → этот фрагмент» читается там, где
|
действием, поэтому связь «эта страница → этот фрагмент» читается там, где
|
||||||
её заводят.
|
её заводят.
|
||||||
|
|
||||||
|
### HTMX-35. Запрос без поля поверхности получает 400
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Обработчик, различающий поверхности (HTMX-28), отвечает статусом
|
||||||
|
400, когда поля `surface` в запросе нет; поверхность по умолчанию не
|
||||||
|
выбирается.
|
||||||
|
|
||||||
|
**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
|
||||||
|
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
|
||||||
|
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
|
||||||
|
чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
|
||||||
|
как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением
|
||||||
|
глобального слушателя (HTMX-34) — сразу и на той странице, где форму сломали.
|
||||||
|
Вкладка, открытая до появления поля, получает тот же 400 и чинится
|
||||||
|
перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной
|
||||||
|
разметке.
|
||||||
|
|
||||||
## Статика, вендоринг, кэш
|
## Статика, вендоринг, кэш
|
||||||
|
|
||||||
Раздел не про htmx — это упаковка любого server-rendered приложения;
|
Раздел не про htmx — это упаковка любого server-rendered приложения;
|
||||||
разъедется в языковой слой, когда понадобится там.
|
разъедется в языковой слой, когда понадобится там.
|
||||||
|
|
||||||
### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
|
### HTMX-29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
|
||||||
|
|
||||||
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
|
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
|
||||||
`Cache-Control: public, max-age=31536000, immutable`.
|
`Cache-Control: public, max-age=31536000, immutable`.
|
||||||
@@ -407,21 +446,21 @@ htmx-атрибутов при этом само работает маркеро
|
|||||||
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
|
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
|
||||||
шага раскладки файлов, который может отстать от бинаря и оставить новую
|
шага раскладки файлов, который может отстать от бинаря и оставить новую
|
||||||
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
|
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
|
||||||
меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы
|
меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
|
||||||
способом навсегда закрепить у пользователя старый файл.
|
был бы способом навсегда закрепить у пользователя старый файл.
|
||||||
|
|
||||||
### R30. Меняемые ассеты версионируются хешем содержимого
|
### HTMX-30. Меняемые ассеты версионируются хешем содержимого
|
||||||
|
|
||||||
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
|
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
|
||||||
строит хелпер шаблона.
|
строит хелпер шаблона.
|
||||||
|
|
||||||
**Почему.** Хеш содержимого — единственная версия, которую невозможно
|
**Почему.** Хеш содержимого — единственная версия, которую невозможно
|
||||||
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
|
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
|
||||||
от этого не защищают, а цена промаха при иммутабельном кэше (R29) —
|
от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
|
||||||
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
|
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
|
||||||
хеш не проставляли в каждом шаблоне руками.
|
хеш не проставляли в каждом шаблоне руками.
|
||||||
|
|
||||||
### R31. Вендорный ассет в `?v=` не нуждается
|
### HTMX-31. Вендорный ассет в `?v=` не нуждается
|
||||||
|
|
||||||
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
|
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
|
||||||
параметра версии.
|
параметра версии.
|
||||||
@@ -429,9 +468,9 @@ htmx-атрибутов при этом само работает маркеро
|
|||||||
**Почему.** Содержимое под этим именем не меняется: обновление вендора
|
**Почему.** Содержимое под этим именем не меняется: обновление вендора
|
||||||
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
|
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
|
||||||
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
|
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
|
||||||
явное разрешение снимает вопрос, не нарушает ли это R30.
|
явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
|
||||||
|
|
||||||
### R32. Вендор не коммитится, а добывается по манифесту
|
### HTMX-32. Вендор не коммитится, а добывается по манифесту
|
||||||
|
|
||||||
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
|
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
|
||||||
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
|
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
|
||||||
@@ -443,7 +482,7 @@ diff'е — у закоммиченного минифицированного
|
|||||||
единственная проверка, что скачали то же самое, что проверяли; зависимость
|
единственная проверка, что скачали то же самое, что проверяли; зависимость
|
||||||
сборки от задачи не даёт собраться без ассета в свежем клоне.
|
сборки от задачи не даёт собраться без ассета в свежем клоне.
|
||||||
|
|
||||||
### R33. Шрифты и скрипты — self-hosted
|
### HTMX-33. Шрифты и скрипты — self-hosted
|
||||||
|
|
||||||
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.
|
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Реестр префиксов правил.
|
||||||
|
#
|
||||||
|
# Префикс — четыре заглавные латинские буквы, уникальные по всему канону.
|
||||||
|
# Он выбирается под файл, а не выводится по формуле: префикс нужен, чтобы
|
||||||
|
# по нему искать, а не чтобы его разбирать. Поэтому подходящее слово лучше
|
||||||
|
# закономерности.
|
||||||
|
#
|
||||||
|
# Правила реестра:
|
||||||
|
#
|
||||||
|
# - префикс не переименовывается и не переиспользуется никогда — ссылка
|
||||||
|
# из чужого репозитория обязана продолжать указывать на то же место;
|
||||||
|
# - при удалении или разделении файла префикс уходит в [retired], а не
|
||||||
|
# освобождается;
|
||||||
|
# - переезд файла между осями префикс не меняет: идентификатор правила
|
||||||
|
# не зависит от таксономии;
|
||||||
|
# - вынос части правил в новый файл — это новый префикс и новая
|
||||||
|
# нумерация: перенос правила между документами есть смысловое
|
||||||
|
# изменение, а не переименование;
|
||||||
|
# - тот же префикс продублирован в шапке файла (`prefix:`), conv сверяет.
|
||||||
|
#
|
||||||
|
# Пути даются от корня репозитория, а не от `conventions/`: реестр покрывает
|
||||||
|
# и обвязку тоже.
|
||||||
|
#
|
||||||
|
# Локальные правила репозиториев берут свои префиксы и объявляют их в
|
||||||
|
# `.conventions.toml` копии. Они обязаны не пересекаться с этим реестром.
|
||||||
|
|
||||||
|
[live]
|
||||||
|
DIRS = "conventions/arch/app-directories.md"
|
||||||
|
CONF = "conventions/arch/config.md"
|
||||||
|
KEYS = "conventions/arch/db-identifiers.md"
|
||||||
|
TIME = "conventions/arch/time.md"
|
||||||
|
GCFG = "conventions/lang/go/config.md"
|
||||||
|
GKEY = "conventions/lang/go/db-identifiers.md"
|
||||||
|
MIGR = "conventions/lang/go/db-schema.md"
|
||||||
|
GERR = "conventions/lang/go/errors.md"
|
||||||
|
SLOG = "conventions/lang/go/logging.md"
|
||||||
|
GTIM = "conventions/lang/go/time.md"
|
||||||
|
ANSD = "conventions/stack/ansible/app-directories.md"
|
||||||
|
HTMX = "conventions/stack/htmx/web-ui.md"
|
||||||
|
|
||||||
|
# Обвязка канона: не синхронизируется в репозитории, но правила
|
||||||
|
# записаны тем же языком и цитируются по номерам, поэтому префикс нужен.
|
||||||
|
META = "GUIDE.md"
|
||||||
|
|
||||||
|
[retired]
|
||||||
|
# Пусто. Сюда попадают префиксы удалённых и разделённых файлов вместе с
|
||||||
|
# причиной и датой, чтобы их нельзя было выдать повторно.
|
||||||
Reference in New Issue
Block a user