заведён канон общих конвенций для личных проектов
- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Категории директорий приложения
|
||||
|
||||
Всё, что приложение пишет на диск, делится на три категории по принципу
|
||||
создания и ценности содержимого:
|
||||
|
||||
- **конфигурация** — то, что восстанавливается прогоном деплоя, в том числе
|
||||
секреты;
|
||||
- **данные** — то, что генерирует приложение и что нужно бэкапить;
|
||||
- **кеш** — то, что генерирует приложение и что не нужно бэкапить:
|
||||
приложение перегенерирует заново.
|
||||
|
||||
Цель — упростить оперирование данными. Категория сразу отвечает на два
|
||||
вопроса, которые иначе приходится выяснять по коду приложения: **кто
|
||||
создаёт** содержимое и **что будет, если его потерять**.
|
||||
|
||||
## Категории
|
||||
|
||||
| Категория | Директория | Создаёт | Потеря содержимого | Бэкап |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Конфигурация | `config/` | деплой | восстанавливается прогоном | не нужен |
|
||||
| Данные | `data/` | приложение | невосполнима | обязателен |
|
||||
| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен |
|
||||
|
||||
Имена в таблице — умолчание для случая «одна директория на категорию».
|
||||
**Категория может состоять из нескольких директорий**, и это нормально:
|
||||
крупные файлы отделяют от базы, чтобы двигать их между дисками независимо
|
||||
(`media/`, `uploads/` — та же категория «данные», что и `data/`).
|
||||
Принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
||||
|
||||
Тест на границе данных и кеша: что будет, если сделать `rm -rf` и поднять
|
||||
приложение заново. Поднимется само и наверстает — кеш. Не поднимется или
|
||||
поднимется пустым — данные.
|
||||
|
||||
Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат секреты,
|
||||
а бэкапы уезжают в облако. Источник истины для конфигурации — репозиторий и
|
||||
хранилище секретов, а не снапшот бэкапа.
|
||||
|
||||
## Данные, которые нельзя копировать на живую
|
||||
|
||||
Файловый снапшот работающей СУБД не гарантирует консистентности:
|
||||
скопированный каталог может не восстановиться. Поэтому у категории «данные»
|
||||
есть два способа попасть в бэкап:
|
||||
|
||||
- **копированием** — если файлы самодостаточны на любой момент времени;
|
||||
- **дампом** — если консистентность обеспечивает только сама СУБД. Тогда
|
||||
бэкапится директория дампов, а сырой каталог базы — нет.
|
||||
|
||||
Директория дампов — тоже данные, просто производные. Решение «копировать
|
||||
или дампить» принимается **при заведении приложения**, а не при первой
|
||||
неудачной попытке восстановления.
|
||||
|
||||
## Контракт с приложением
|
||||
|
||||
Категории — не только про деплой. Приложение **разводит свои записываемые
|
||||
пути по категориям в конфигурации**, а не складывает всё в один каталог:
|
||||
иначе категорию нельзя определить снаружи и список бэкапа приходится
|
||||
составлять вручную, читая код.
|
||||
|
||||
- Путь к БД, загруженным файлам, сгенерированным артефактам — данные.
|
||||
- Миниатюры, распакованные ассеты, кеш внешних ответов, индексы, которые
|
||||
перестраиваются, — кеш. Даже если их дорого перестраивать: дорого ≠
|
||||
невосполнимо.
|
||||
- Приложение не пишет в директорию конфигурации: она может быть доступна
|
||||
только на чтение.
|
||||
|
||||
Если приложение не умеет разделять, это его дефект, а не повод смешивать
|
||||
категории в раскладке.
|
||||
|
||||
## Список бэкапа выводится, а не составляется
|
||||
|
||||
Список бэкапа получается из категорий по правилу: туда идут данные, не идут
|
||||
конфигурация и кеш. Правило механическое — но его применяет человек или
|
||||
шаблон, поэтому список обязан ссылаться на **те же** переменные путей, что
|
||||
и создание директорий. Независимо набранный список — источник расхождения
|
||||
между тем, что бэкапится, и тем, что нужно.
|
||||
|
||||
## Область действия
|
||||
|
||||
Раскладка меняется вместе с миграцией данных, поэтому конвенция применяется
|
||||
к **новым приложениям**; существующие переезжают по мере касания, отдельной
|
||||
кампанией не переписываются. Разделять данные и кеш задним числом имеет
|
||||
смысл тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы.
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
<!-- local:эталон -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Конфигурация приложения
|
||||
|
||||
Как устроена конфигурация: где лежит, как попадает в процесс, что с
|
||||
секретами и когда падает.
|
||||
|
||||
## Файл, а не окружение
|
||||
|
||||
**Конфигурация — файл.** Причины, по убыванию веса:
|
||||
|
||||
- **Один типизированный источник.** Файл несёт секции, комментарии,
|
||||
единицы измерения и валидируется целиком. Окружение — плоский набор
|
||||
нетипизированных строк, который приходится документировать отдельно;
|
||||
появление второго канала конфигурации гарантирует расхождение между ними.
|
||||
- **Окружение наследуется дочерними процессами.** Всё, что приложение
|
||||
запускает — конвертер, `git`, шелл-хук, — по умолчанию получает копию
|
||||
секретов, хотя они ему не нужны.
|
||||
- **В контейнере окружение расползается по лишним поверхностям.**
|
||||
`docker inspect` показывает его любому, у кого есть доступ к сокету
|
||||
докера; переменные оседают в compose-файле и `.env` на диске — то есть
|
||||
файл всё равно появляется, только без структуры и валидации.
|
||||
|
||||
Обратите внимание, чего в списке **нет**: `/proc/<pid>/environ` не является
|
||||
аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`,
|
||||
то есть доступен ровно тому же кругу, что и файл под `0600`.
|
||||
|
||||
Запрет держится на «один источник» и на том, что все приложения свои. Для
|
||||
стороннего образа, живущего на env, конвенция неприменима — это не повод
|
||||
отказываться от неё для своих.
|
||||
|
||||
Практика:
|
||||
|
||||
- Формат — текстовый, с комментариями и секциями (TOML, YAML — по стеку).
|
||||
- Имя по умолчанию фиксировано и ищется в рабочей директории процесса;
|
||||
путь переопределяется опцией командной строки.
|
||||
- Реальный конфиг не коммитится. В репозитории лежит **образец**.
|
||||
|
||||
## Грузим один раз, дальше не перечитываем
|
||||
|
||||
- Разбор — **один раз при старте**, в одну типизированную структуру.
|
||||
Дальше по коду читаем только её: чтения файла в бизнес-коде нет.
|
||||
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
||||
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
|
||||
умолчание.
|
||||
- Умолчания задаются в коде, файл их перекрывает. Образец при этом
|
||||
перечисляет **все** поля, включая те, у которых есть умолчание: поле,
|
||||
живущее только в коде, для читателя конфига не существует.
|
||||
|
||||
## Образец самодокументируем
|
||||
|
||||
Образец коммитим как единый справочник по конфигу: все секции и все поля.
|
||||
**Каждое поле снабжаем комментарием**, из которого ясно:
|
||||
|
||||
- **зачем** поле — что оно меняет в поведении;
|
||||
- **диапазон или допустимые значения** — перечисление либо границы;
|
||||
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
|
||||
`0–1`.
|
||||
|
||||
Так конфиг читается без открывания кода — этим он и полезен.
|
||||
|
||||
## Поля по дискриминатору `type`
|
||||
|
||||
Когда набор полей секции зависит от поля-дискриминатора (выбор одного из
|
||||
бекендов или внешних сервисов), обязательность полей определяется его
|
||||
значением, а не фиксирована для секции.
|
||||
|
||||
- **Валидация — по значению `type`**: для каждого поддерживаемого варианта
|
||||
свой набор обязательных полей; поля других вариантов не требуются.
|
||||
Неизвестное значение → ошибка на старте с перечислением поддерживаемых.
|
||||
- **Образец — по значению `type`**: основной вариант предзаполнен рабочими
|
||||
значениями, альтернативные — блоками-комментариями ниже, каждый со своим
|
||||
описанием полей. Из примера видны все варианты, не открывая код.
|
||||
|
||||
## Секреты приносит деплой
|
||||
|
||||
Секреты доставляет **деплой**, рендеря их прямо в конфиг. Отдельного слоя
|
||||
секретов в приложении нет — оно просто читает файл. Источник истины
|
||||
секрета — внешнее хранилище деплоя, не репозиторий и не окружение.
|
||||
|
||||
- Рендеренный конфиг не коммитится; права `0600`, владелец — runtime-
|
||||
пользователь.
|
||||
- В образце секретные поля — пустые строки.
|
||||
- Загрузчик на старте проверяет, что обязательные секреты не пусты: это
|
||||
ловит криво отрендеренный шаблон до того, как он превратится в 401 от
|
||||
внешнего API через час работы.
|
||||
- В логи секреты не попадают.
|
||||
|
||||
<!-- local:секретные-поля -->
|
||||
<!-- /local -->
|
||||
|
||||
## Валидация и fail-fast
|
||||
|
||||
Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг —
|
||||
запись уровня `ERROR` и выход с ненулевым кодом: не стартуем «наполовину».
|
||||
|
||||
Проверяем как минимум:
|
||||
|
||||
- обязательные поля заданы, обязательные секреты не пусты;
|
||||
- пути существуют и доступны на запись/чтение по назначению;
|
||||
- числовые диапазоны и единицы (доли, таймауты, счётчики попыток);
|
||||
- строки, которые парсятся во что-то (длительности, зоны, URL), реально
|
||||
парсятся;
|
||||
- включённые секции консистентны: если интеграция включена — заданы все её
|
||||
обязательные поля.
|
||||
|
||||
Проблемы собираем и показываем **разом**, а не по одной за запуск.
|
||||
|
||||
<!-- local:проверки -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/time.md` — формат времени; зона отображения — единственный
|
||||
конфигурируемый параметр времени, семантика описана там.
|
||||
- `arch/app-directories.md` — конфиг лежит в категории «конфигурация» и
|
||||
доступен приложению только на чтение.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Идентификаторы сущностей
|
||||
|
||||
Как выбираются и как выглядят первичные ключи. Схема БД меняется тяжело,
|
||||
поэтому конвенция применяется **к новым таблицам**; существующие живут как
|
||||
есть и перечислены в отступлениях.
|
||||
|
||||
## Условие применимости
|
||||
|
||||
Вопрос задаётся **один раз на репозиторий**, а не по таблицам:
|
||||
|
||||
> Есть ли в приложении хотя бы одна сущность, которую адресуют извне — по
|
||||
> id из URL, запроса API или callback-данных?
|
||||
>
|
||||
> - **Да** → весь репозиторий на сортируемый строковый id, который
|
||||
> генерирует приложение (ULID), включая внутренние таблицы.
|
||||
> - **Ни одной** → автоинкремент, и этого достаточно.
|
||||
|
||||
Критерий — именно **адресация**: снаружи по этому id возвращаются к
|
||||
системе. Не «id виден в логе» — туда рано или поздно попадает любой
|
||||
идентификатор, и по такому критерию вторая ветка была бы недостижима.
|
||||
|
||||
Почему квантор репозиторный, а не потабличный: внутренние сущности имеют
|
||||
привычку становиться внешними, и тогда целочисленный id утекает в URL
|
||||
задним числом. Плюс одна ментальная модель дешевле, чем спор при заведении
|
||||
каждой таблицы.
|
||||
|
||||
**Что не является смешиванием.** Запрет касается двух видов
|
||||
*сгенерированных суррогатных* ключей в одной базе. Естественные и составные
|
||||
ключи у таблиц-деталей — третья категория, они допустимы всегда (см. ниже).
|
||||
|
||||
<!-- local:решение -->
|
||||
<!-- /local -->
|
||||
|
||||
## Если ULID
|
||||
|
||||
- **PK — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||
**приложением** в момент создания записи, а не БД.
|
||||
- Почему не UUID: UUIDv4 не сортируется по времени. UUIDv7 (RFC 9562)
|
||||
сортируется, и против него остаются два довода — 36 символов против 26 и
|
||||
дефисы: без них `grep` и двойной клик берут id целиком.
|
||||
- Сортировка по времени создания даёт `ORDER BY id` = хронология с
|
||||
точностью до миллисекунды. Внутри одной миллисекунды порядок произволен,
|
||||
если генератор не монотонный, — на хронологию событий это не влияет.
|
||||
- Глобальная уникальность across таблиц даёт побочный, но важный эффект:
|
||||
голый `grep` по id находит все записи сущности независимо от имени поля.
|
||||
- **Единая точка генерации и разбора.** Один модуль генерирует id и один
|
||||
разбирает; самодельных генераторов по коду нет.
|
||||
|
||||
## Канонический вид и границы
|
||||
|
||||
- Генерим и храним id в **нижнем регистре**. Сравнение строк в БД обычно
|
||||
побайтовое, поэтому регистр — не косметика, а корректность. Спецификация
|
||||
ULID канонизирует верхний регистр, и библиотеки по умолчанию отдают
|
||||
именно его — нижний обеспечивает единая точка генерации, поэтому звать
|
||||
библиотеку мимо неё нельзя.
|
||||
- Любой пришедший снаружи id **обязательно** проходит разбор до запроса к
|
||||
БД: он валидирует формат и нормализует регистр.
|
||||
- Синтаксически невалидный id, которым **адресуют ресурс**, трактуем как
|
||||
несуществующую сущность (404), **без похода в БД**: это и дешевле, и
|
||||
убирает целый класс запросов с мусором. Невалидный id, пришедший из
|
||||
собственной формы или кнопки, — не «не найдено», а некорректный ввод: там
|
||||
это признак устаревшего интерфейса или бага, и маскировать его под 404
|
||||
значит терять диагностику.
|
||||
|
||||
## Естественные и составные ключи — для деталей
|
||||
|
||||
У таблиц-деталей и связей допустим естественный или составной ключ вместо
|
||||
сгенерированного, когда он есть по природе данных. Отдельный id там —
|
||||
мёртвый вес, который ещё и создаёт второй способ адресовать ту же строку.
|
||||
|
||||
Прочие генерируемые идентификаторы (батчи, задания, корреляционные ключи) —
|
||||
через ту же единую точку: единый формат, сортируемость, корреляция в логах.
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/time.md` — метки времени тоже генерирует приложение, а не схема.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Время
|
||||
|
||||
Один формат времени на всё приложение: хранение, логи, API, обмен с
|
||||
внешними системами. Разные форматы в разных слоях — источник ошибок,
|
||||
которые всплывают через полгода на границе перехода на летнее время.
|
||||
|
||||
## Формат
|
||||
|
||||
- **RFC 3339, UTC, суффикс `Z`**: `2026-06-28T11:23:45Z`.
|
||||
- **Ширина фиксируется на каждый носитель** и внутри него не плавает.
|
||||
Лексикографическая сортировка равна хронологии только среди строк
|
||||
одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
|
||||
хронологически позже. Ради этого формат и фиксируется — `ORDER BY
|
||||
created_at` по текстовому полю обязан давать порядок событий.
|
||||
- Разные носители могут иметь разную точность: строки БД и строки лога
|
||||
между собой никогда не сравниваются. Требование — не «одна точность на
|
||||
приложение», а «внутри колонки и внутри потока логов ширина одна».
|
||||
- Локальное время не хранится и не передаётся **нигде** — ни в БД, ни в
|
||||
логах, ни в JSON API.
|
||||
|
||||
## Генерирует приложение, а не хранилище
|
||||
|
||||
- Единая точка получения «сейчас» и единая точка форматирования и разбора —
|
||||
как с идентификаторами (`arch/db-identifiers.md`). Прямые вызовы часов по
|
||||
коду не разбросаны: иначе ни формат, ни зона не гарантированы.
|
||||
- **Дефолты в схеме БД не используем.** Забытая вставка `created_at`
|
||||
должна падать громко, а не тихо получать значение от БД — иначе
|
||||
расходятся источник времени (сервер БД) и его формат.
|
||||
|
||||
## Длительность — не метка времени
|
||||
|
||||
Измерение длительности операции — отдельная величина: число (обычно
|
||||
миллисекунды) в поле вида `duration_ms`, а не разность двух меток и не
|
||||
время в формате выше. Засекает её тот слой, который делает вызов.
|
||||
|
||||
**Интервал измеряется монотонными часами процесса**, а не вычитанием
|
||||
сохранённых меток: стенные часы подводит NTP, они могут шагнуть назад и
|
||||
дать отрицательную длительность. Из этого следует, что источник меток
|
||||
времени и источник интервалов — разные, даже если оба называются «часы».
|
||||
|
||||
## Зоны
|
||||
|
||||
Единственное место, где появляется не-UTC, — **отображение пользователю**.
|
||||
Зона берётся из конфигурации (`arch/config.md`), значение по умолчанию —
|
||||
`UTC`. На хранение, сортировку и логи она не влияет.
|
||||
|
||||
Если бизнес-логика оперирует календарными сущностями («сегодня»,
|
||||
«за месяц»), зона указывается **явно** в месте вычисления — молчаливое
|
||||
использование системной зоны процесса запрещено: она разная на ноутбуке и в
|
||||
контейнере. По умолчанию это та же зона, что и для отображения; если
|
||||
календарная логика требует другой, это записывается явно.
|
||||
|
||||
Конвенция описывает фиксацию **свершившихся моментов**. Планирование
|
||||
будущих событий — отдельный случай (там хранят локальное время плюс имя
|
||||
зоны, потому что правила зон меняются); пока такой сущности нет, правило не
|
||||
формулируем.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/config.md` — где задаётся зона отображения.
|
||||
- `arch/db-identifiers.md` — то же правило «генерирует приложение» для id.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
Reference in New Issue
Block a user