db-identifiers и ansible/app-directories переписаны на формальный язык

- 7 и 9 правил соответственно, у каждого модальность и обоснование
- условие выбора первичного ключа стало правилом с таблицей веток, на
  которые можно ссылаться из отступлений и механизации
This commit is contained in:
av
2026-07-25 18:56:30 +03:00
parent 22c6855968
commit 7701a28df1
2 changed files with 203 additions and 98 deletions
+109 -58
View File
@@ -1,79 +1,130 @@
---
status: рекомендуемая
---
# Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи. Схема БД меняется тяжело,
поэтому конвенция применяется **к новым таблицам**; существующие живут как
есть и перечислены в отступлениях.
Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
`common/language.md`.
## Условие применимости
## Область действия
Вопрос задаётся **один раз на репозиторий**, а не по таблицам:
Схема базы меняется тяжело: таблица не переезжает от того, что её
потрогали. Поэтому правила распространяются на **новые таблицы**;
существующие живут как есть и перечисляются в отступлениях, причём этот
список постоянный, а не список задач на дочистку.
> Есть ли в приложении хотя бы одна сущность, которую адресуют извне — по
> id из URL, запроса API или callback-данных?
>
> - **Да** → весь репозиторий на сортируемый строковый id, который
> генерирует приложение (ULID), включая внутренние таблицы.
> - **Ни одной** → автоинкремент, и этого достаточно.
## Правила
Критерий — именно **адресация**: снаружи по этому id возвращаются к
системе. Не «id виден в логе» — туда рано или поздно попадает любой
идентификатор, и по такому критерию вторая ветка была бы недостижима.
### R1. Вид первичного ключа выбирается один раз на репозиторий
Почему квантор репозиторный, а не потабличный: внутренние сущности имеют
привычку становиться внешними, и тогда целочисленный id утекает в URL
задним числом. Плюс одна ментальная модель дешевле, чем спор при заведении
каждой таблицы.
**ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех
своих таблиц:
**Что не является смешиванием.** Запрет касается двух видов
*сгенерированных суррогатных* ключей в одной базе. Естественные и составные
ключи у таблиц-деталей — третья категория, они допустимы всегда (см. ниже).
> Есть ли в приложении хотя бы одна сущность, которую адресуют **извне** —
> по идентификатору из URL, запроса API или callback-данных?
| № | Ответ | Вид ключа |
|---|---|---|
| R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние |
| R1.2 | ни одной | автоинкремент |
**Почему.** Квантор репозиторный, а не потабличный, по двум причинам.
Внутренние сущности имеют привычку становиться внешними — и тогда
целочисленный идентификатор утекает в URL задним числом, а миграция ключа
на живых данных стоит несопоставимо дороже, чем взять строковый сразу.
Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет
от спора при заведении каждой таблицы.
Критерий — именно **адресация**: снаружи по этому идентификатору
возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает
любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима.
Запрет смешивания касается двух видов **сгенерированных суррогатных**
ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья
категория, они допустимы при любом ответе.
<!-- local:решение -->
<!-- /local -->
## Если ULID
### R2. При выборе R1.1 идентификатор генерирует приложение, а не база
- **PK — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи, а не БД.
- Почему не UUID: UUIDv4 не сортируется по времени. UUIDv7 (RFC 9562)
сортируется, и против него остаются два довода — 36 символов против 26 и
дефисы: без них `grep` и двойной клик берут id целиком.
- Сортировка по времени создания даёт `ORDER BY id` = хронология с
точностью до миллисекунды. Внутри одной миллисекунды порядок произволен,
если генератор не монотонный, — на хронологию событий это не влияет.
- Глобальная уникальность across таблиц даёт побочный, но важный эффект:
голый `grep` по id находит все записи сущности независимо от имени поля.
- **Единая точка генерации и разбора.** Один модуль генерирует id и один
разбирает; самодельных генераторов по коду нет.
**ДОЛЖЕН.** Значение ключа известно до вставки строки.
## Канонический вид и границы
**Почему.** Идентификатор нужен раньше, чем база ответит: его пишут в лог
начатой операции, кладут в связанные записи одной транзакции и возвращают
клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid`
и достраивать связи вторым проходом, либо иметь два источника истины о
моменте создания.
- Генерим и храним id в **нижнем регистре**. Сравнение строк в БД обычно
побайтовое, поэтому регистр — не косметика, а корректность. Спецификация
ULID канонизирует верхний регистр, и библиотеки по умолчанию отдают
именно его — нижний обеспечивает единая точка генерации, поэтому звать
библиотеку мимо неё нельзя.
- Любой пришедший снаружи id **обязательно** проходит разбор до запроса к
БД: он валидирует формат и нормализует регистр.
- Синтаксически невалидный id, которым **адресуют ресурс**, трактуем как
несуществующую сущность (404), **без похода в БД**: это и дешевле, и
убирает целый класс запросов с мусором. Невалидный id, пришедший из
собственной формы или кнопки, — не «не найдено», а некорректный ввод: там
это признак устаревшего интерфейса или бага, и маскировать его под 404
значит терять диагностику.
### R3. Генерация и разбор идентификаторов — в единственной точке
## Естественные и составные ключи — для деталей
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
Самодельных генераторов и парсеров в коде нет.
У таблиц-деталей и связей допустим естественный или составной ключ вместо
сгенерированного, когда он есть по природе данных. Отдельный id там —
мёртвый вес, который ещё и создаёт второй способ адресовать ту же строку.
**Почему.** Нормализация регистра (R4) и проверка формата обязаны
применяться ко всем идентификаторам без исключения. Любая вторая точка
входа рано или поздно окажется той, где нормализацию забыли, — и дефект
проявится не там, где создан.
Прочие генерируемые идентификаторы (батчи, задания, корреляционные ключи) —
через ту же единую точку: единый формат, сортируемость, корреляция в логах.
### R4. Канонический вид — нижний регистр
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3)
разный регистр появится в базе сам собой.
### R5. Внешний идентификатор разбирается до обращения к базе
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от
источника:
| № | Откуда пришёл | Разбор не удался → |
|---|---|---|
| R5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| R5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
**Почему.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
границе, мы дёшево снимаем целый класс мусорного трафика.
Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в
URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию
точно. Мусор из собственной формы — это баг интерфейса или устаревший
экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики
единственный момент, когда он заметен.
### R6. У таблиц-деталей допустим естественный или составной ключ
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
сгенерированный идентификатор не заводится.
**Почему.** Суррогат поверх естественного ключа создаёт второй способ
адресовать ту же строку — а значит, возможность рассинхрона между ними и
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
информации он не несёт.
### R7. Прочие генерируемые идентификаторы — через ту же точку
**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч,
задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же
формате.
**Почему.** Единый формат делает работающим главный побочный эффект
строковых идентификаторов: `grep` по голому значению собирает все
упоминания сущности в логах независимо от имени поля. Второй формат
идентификаторов эту возможность отменяет ровно для тех записей, где она
чаще всего нужна.
## Почему ULID, а не UUID
Ветка R1.1 требует **сортируемый** строковый идентификатор. UUIDv4 не
сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него
остаются два довода: 36 символов против 26 и дефисы, из-за которых
идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово.
Сортировка даёт `ORDER BY id` = хронология с точностью до миллисекунды;
внутри одной миллисекунды порядок произволен, если генератор не монотонный,
— на порядок событий это не влияет.
<!-- local:отступления -->
<!-- /local -->
+94 -40
View File
@@ -1,63 +1,117 @@
---
status: рекомендуемая
extends: arch/app-directories.md
---
# Категории директорий: реализация в Ansible
Как `arch/app-directories.md` раскладывается на сервере плейбуком.
Как категории из `arch/app-directories.md` раскладываются на сервере
плейбуком. Форма записи — `common/language.md`.
## Переменные и создание
## Область действия
- Директория объявляется переменной плейбука внутри `base_dir`, имя
переменной оканчивается на `_dir`. Для случая «одна директория на
категорию» это `config_dir`, `data_dir`, `cache_dir`; когда категория
состоит из нескольких, имя даётся по содержимому (`media_dir`,
`uploads_dir`, `dumps_dir`), а категория читается из списка бэкапа.
- Директории создаются **одной задачей циклом по списку**: список и есть
декларация того, что приложение пишет на диск. Разнесение по нескольким
задачам прячет эту декларацию.
- Владелец — пользователь, от имени которого работает приложение. Модель
выбирается на репозиторий: выделенный пользователь на приложение
(`app_owner_uid == app_owner_gid`) или общий `primary_user`. Какая модель
принята — фиксируется ниже.
Раскладка меняется вместе с миграцией данных, поэтому правила
распространяются на **новые приложения**; существующие переезжают по мере
касания, отдельной кампанией не переписываются.
## Правила
### R1. Каждая директория объявлена переменной `*_dir`
**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри
`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на
категорию» это `config_dir`, `data_dir`, `cache_dir`; когда категория
состоит из нескольких директорий, имя даётся по содержимому (`media_dir`,
`uploads_dir`, `dumps_dir`).
**Почему.** Переменная — единственная ссылка, которую разделяют задача
создания директории и список бэкапа (R4). Литерал пути в одном из этих мест
означает, что переименование директории молча разойдётся с бэкапом, и
обнаружится это при восстановлении.
### R2. Директории создаются одной задачей циклом по списку
**СЛЕДУЕТ.** Список директорий в единственной задаче создания.
**Почему.** Этот список — единственное место, где декларировано всё, что
приложение пишет на диск. Разнесённое по нескольким задачам создание
отвечает на вопрос «какие директории есть у приложения» только чтением
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
разборе места на диске.
### R3. Владелец директорий — пользователь, от имени которого работает приложение
**ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
репозиторий и фиксируется ниже.
**Почему.** Правило про соответствие владельца рантайму, а не про
конкретную модель: приложение в контейнере пишет от определённого uid, и
если директория принадлежит другому, отказ произойдёт не при деплое, а при
первой записи — то есть после того, как плейбук отчитался об успехе. Выбор
же модели — свойство репозитория: сервер с одним пользователем и сервер с
изоляцией по приложениям решают разные задачи, и навязывать одну модель
обоим значит гарантировать вечное отступление.
<!-- local:модель-владельца -->
<!-- /local -->
## Список бэкапа
### R4. Список бэкапа собирается из тех же переменных
Плейбук кладёт в `base_dir` файл `backup-targets` — его читает оркестратор
бэкапов. Строки списка собираются из **тех же** переменных `*_dir`, что и
задача создания директорий: тогда переименование или перенос директории не
может разойтись с бэкапом.
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути.
В список идут директории категории «данные», включая директорию дампов, и
не идут конфигурация и кеш.
**Почему.** Правило вывода списка механическое (R5), но применяет его
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
класс ошибок невозможным: переименовал директорию — переименовалось в
обоих местах. Независимо набранный список расходится тихо и проявляется в
единственный момент, когда это уже неисправимо.
## Монтирование в контейнер
### R5. В список бэкапа идут только данные
- Конфигурация — `:ro`, где приложение это позволяет. Приложение, которое
переписывает свой конфиг, монтируется на запись — это отступление, и оно
записывается.
- Данные и кеш — на запись.
- `docker-compose.yml` остаётся в корне `base_dir`: туда смотрит
`project_src` модуля `docker_compose_v2`.
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
списке; конфигурация и кеш — нет.
## Секреты
**Почему.** Реализация правила базовой конвенции. Кеш раздувает снапшот без
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
облако, и источником истины для секретов остаётся vault, а не снапшот.
Секреты приходят из vault-переменных и рендерятся шаблоном. Два способа, в
порядке предпочтения:
### R6. Конфигурация монтируется только на чтение
1. **В файл конфигурации** (роль `secrets`) — предпочтительный: секрет
лежит под `0600` у пользователя приложения, не наследуется дочерними
процессами и не виден в `docker inspect`.
2. **В `environment:` compose-файла** — когда приложение не умеет читать
секреты из файла. Задача рендера идёт с `no_log: true`.
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
Второй способ — вынужденный: он кладёт секрет в метаданные контейнера и в
файл compose на диске. Приложение, умеющее файловые секреты, переводится на
первый способ при ближайшем касании.
**Почему.** Плейбук — источник истины для конфигурации, и `:ro` превращает
это из договорённости в свойство системы: приложение, которое втихую
переписывает свой конфиг, падает сразу, а не расходится с репозиторием
незаметно. Приложение, которому запись в конфиг нужна по устройству,
монтируется на запись — это отступление, и оно записывается.
### R7. `docker-compose.yml` лежит в корне `base_dir`
**ДОЛЖЕН.** Файл не переносится во вложенную директорию.
**Почему.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило
внешнее по происхождению, но нарушается легко — при попытке «навести
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
место.
### R8. Секреты рендерятся в файл конфигурации
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
принадлежащий пользователю приложения.
**Почему.** Файл под `0600` не наследуется дочерними процессами, не виден в
`docker inspect` и не оседает в compose-файле на диске. Это те же три
довода, по которым базовая конвенция конфигурации выбирает файл вместо
окружения.
### R9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
**Почему.** Явное разрешение нужно, чтобы R8 не читался как запрет на
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
контейнера и в compose-файл на диске. Приложение, научившееся читать
секреты из файла, переводится на R8 при ближайшем касании.
<!-- local:отступления -->
<!-- /local -->