db-identifiers и ansible/app-directories переписаны на формальный язык
- 7 и 9 правил соответственно, у каждого модальность и обоснование - условие выбора первичного ключа стало правилом с таблицей веток, на которые можно ссылаться из отступлений и механизации
This commit is contained in:
+109
-58
@@ -1,79 +1,130 @@
|
|||||||
---
|
|
||||||
status: рекомендуемая
|
|
||||||
---
|
|
||||||
|
|
||||||
# Идентификаторы сущностей
|
# Идентификаторы сущностей
|
||||||
|
|
||||||
Как выбираются и как выглядят первичные ключи. Схема БД меняется тяжело,
|
Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
|
||||||
поэтому конвенция применяется **к новым таблицам**; существующие живут как
|
`common/language.md`.
|
||||||
есть и перечислены в отступлениях.
|
|
||||||
|
|
||||||
## Условие применимости
|
## Область действия
|
||||||
|
|
||||||
Вопрос задаётся **один раз на репозиторий**, а не по таблицам:
|
Схема базы меняется тяжело: таблица не переезжает от того, что её
|
||||||
|
потрогали. Поэтому правила распространяются на **новые таблицы**;
|
||||||
|
существующие живут как есть и перечисляются в отступлениях, причём этот
|
||||||
|
список постоянный, а не список задач на дочистку.
|
||||||
|
|
||||||
> Есть ли в приложении хотя бы одна сущность, которую адресуют извне — по
|
## Правила
|
||||||
> id из URL, запроса API или callback-данных?
|
|
||||||
>
|
|
||||||
> - **Да** → весь репозиторий на сортируемый строковый id, который
|
|
||||||
> генерирует приложение (ULID), включая внутренние таблицы.
|
|
||||||
> - **Ни одной** → автоинкремент, и этого достаточно.
|
|
||||||
|
|
||||||
Критерий — именно **адресация**: снаружи по этому id возвращаются к
|
### R1. Вид первичного ключа выбирается один раз на репозиторий
|
||||||
системе. Не «id виден в логе» — туда рано или поздно попадает любой
|
|
||||||
идентификатор, и по такому критерию вторая ветка была бы недостижима.
|
|
||||||
|
|
||||||
Почему квантор репозиторный, а не потабличный: внутренние сущности имеют
|
**ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех
|
||||||
привычку становиться внешними, и тогда целочисленный id утекает в URL
|
своих таблиц:
|
||||||
задним числом. Плюс одна ментальная модель дешевле, чем спор при заведении
|
|
||||||
каждой таблицы.
|
|
||||||
|
|
||||||
**Что не является смешиванием.** Запрет касается двух видов
|
> Есть ли в приложении хотя бы одна сущность, которую адресуют **извне** —
|
||||||
*сгенерированных суррогатных* ключей в одной базе. Естественные и составные
|
> по идентификатору из URL, запроса API или callback-данных?
|
||||||
ключи у таблиц-деталей — третья категория, они допустимы всегда (см. ниже).
|
|
||||||
|
| № | Ответ | Вид ключа |
|
||||||
|
|---|---|---|
|
||||||
|
| R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние |
|
||||||
|
| R1.2 | ни одной | автоинкремент |
|
||||||
|
|
||||||
|
**Почему.** Квантор репозиторный, а не потабличный, по двум причинам.
|
||||||
|
Внутренние сущности имеют привычку становиться внешними — и тогда
|
||||||
|
целочисленный идентификатор утекает в URL задним числом, а миграция ключа
|
||||||
|
на живых данных стоит несопоставимо дороже, чем взять строковый сразу.
|
||||||
|
Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет
|
||||||
|
от спора при заведении каждой таблицы.
|
||||||
|
|
||||||
|
Критерий — именно **адресация**: снаружи по этому идентификатору
|
||||||
|
возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает
|
||||||
|
любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима.
|
||||||
|
|
||||||
|
Запрет смешивания касается двух видов **сгенерированных суррогатных**
|
||||||
|
ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья
|
||||||
|
категория, они допустимы при любом ответе.
|
||||||
|
|
||||||
<!-- local:решение -->
|
<!-- local:решение -->
|
||||||
<!-- /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 в **нижнем регистре**. Сравнение строк в БД обычно
|
### R3. Генерация и разбор идентификаторов — в единственной точке
|
||||||
побайтовое, поэтому регистр — не косметика, а корректность. Спецификация
|
|
||||||
ULID канонизирует верхний регистр, и библиотеки по умолчанию отдают
|
|
||||||
именно его — нижний обеспечивает единая точка генерации, поэтому звать
|
|
||||||
библиотеку мимо неё нельзя.
|
|
||||||
- Любой пришедший снаружи id **обязательно** проходит разбор до запроса к
|
|
||||||
БД: он валидирует формат и нормализует регистр.
|
|
||||||
- Синтаксически невалидный id, которым **адресуют ресурс**, трактуем как
|
|
||||||
несуществующую сущность (404), **без похода в БД**: это и дешевле, и
|
|
||||||
убирает целый класс запросов с мусором. Невалидный id, пришедший из
|
|
||||||
собственной формы или кнопки, — не «не найдено», а некорректный ввод: там
|
|
||||||
это признак устаревшего интерфейса или бага, и маскировать его под 404
|
|
||||||
значит терять диагностику.
|
|
||||||
|
|
||||||
## Естественные и составные ключи — для деталей
|
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
|
||||||
|
Самодельных генераторов и парсеров в коде нет.
|
||||||
|
|
||||||
У таблиц-деталей и связей допустим естественный или составной ключ вместо
|
**Почему.** Нормализация регистра (R4) и проверка формата обязаны
|
||||||
сгенерированного, когда он есть по природе данных. Отдельный id там —
|
применяться ко всем идентификаторам без исключения. Любая вторая точка
|
||||||
мёртвый вес, который ещё и создаёт второй способ адресовать ту же строку.
|
входа рано или поздно окажется той, где нормализацию забыли, — и дефект
|
||||||
|
проявится не там, где создан.
|
||||||
|
|
||||||
Прочие генерируемые идентификаторы (батчи, задания, корреляционные ключи) —
|
### 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:отступления -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
@@ -1,63 +1,117 @@
|
|||||||
---
|
---
|
||||||
status: рекомендуемая
|
|
||||||
extends: arch/app-directories.md
|
extends: arch/app-directories.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# Категории директорий: реализация в Ansible
|
# Категории директорий: реализация в 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`), а категория читается из списка бэкапа.
|
## Правила
|
||||||
- Директории создаются **одной задачей циклом по списку**: список и есть
|
|
||||||
декларация того, что приложение пишет на диск. Разнесение по нескольким
|
### R1. Каждая директория объявлена переменной `*_dir`
|
||||||
задачам прячет эту декларацию.
|
|
||||||
- Владелец — пользователь, от имени которого работает приложение. Модель
|
**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри
|
||||||
выбирается на репозиторий: выделенный пользователь на приложение
|
`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на
|
||||||
(`app_owner_uid == app_owner_gid`) или общий `primary_user`. Какая модель
|
категорию» это `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:модель-владельца -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
## Список бэкапа
|
### R4. Список бэкапа собирается из тех же переменных
|
||||||
|
|
||||||
Плейбук кладёт в `base_dir` файл `backup-targets` — его читает оркестратор
|
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
|
||||||
бэкапов. Строки списка собираются из **тех же** переменных `*_dir`, что и
|
которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути.
|
||||||
задача создания директорий: тогда переименование или перенос директории не
|
|
||||||
может разойтись с бэкапом.
|
|
||||||
|
|
||||||
В список идут директории категории «данные», включая директорию дампов, и
|
**Почему.** Правило вывода списка механическое (R5), но применяет его
|
||||||
не идут конфигурация и кеш.
|
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
|
||||||
|
класс ошибок невозможным: переименовал директорию — переименовалось в
|
||||||
|
обоих местах. Независимо набранный список расходится тихо и проявляется в
|
||||||
|
единственный момент, когда это уже неисправимо.
|
||||||
|
|
||||||
## Монтирование в контейнер
|
### R5. В список бэкапа идут только данные
|
||||||
|
|
||||||
- Конфигурация — `:ro`, где приложение это позволяет. Приложение, которое
|
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
|
||||||
переписывает свой конфиг, монтируется на запись — это отступление, и оно
|
списке; конфигурация и кеш — нет.
|
||||||
записывается.
|
|
||||||
- Данные и кеш — на запись.
|
|
||||||
- `docker-compose.yml` остаётся в корне `base_dir`: туда смотрит
|
|
||||||
`project_src` модуля `docker_compose_v2`.
|
|
||||||
|
|
||||||
## Секреты
|
**Почему.** Реализация правила базовой конвенции. Кеш раздувает снапшот без
|
||||||
|
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
|
||||||
|
облако, и источником истины для секретов остаётся vault, а не снапшот.
|
||||||
|
|
||||||
Секреты приходят из vault-переменных и рендерятся шаблоном. Два способа, в
|
### R6. Конфигурация монтируется только на чтение
|
||||||
порядке предпочтения:
|
|
||||||
|
|
||||||
1. **В файл конфигурации** (роль `secrets`) — предпочтительный: секрет
|
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
|
||||||
лежит под `0600` у пользователя приложения, не наследуется дочерними
|
|
||||||
процессами и не виден в `docker inspect`.
|
|
||||||
2. **В `environment:` compose-файла** — когда приложение не умеет читать
|
|
||||||
секреты из файла. Задача рендера идёт с `no_log: true`.
|
|
||||||
|
|
||||||
Второй способ — вынужденный: он кладёт секрет в метаданные контейнера и в
|
**Почему.** Плейбук — источник истины для конфигурации, и `:ro` превращает
|
||||||
файл compose на диске. Приложение, умеющее файловые секреты, переводится на
|
это из договорённости в свойство системы: приложение, которое втихую
|
||||||
первый способ при ближайшем касании.
|
переписывает свой конфиг, падает сразу, а не расходится с репозиторием
|
||||||
|
незаметно. Приложение, которому запись в конфиг нужна по устройству,
|
||||||
|
монтируется на запись — это отступление, и оно записывается.
|
||||||
|
|
||||||
|
### 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:отступления -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
Reference in New Issue
Block a user