29. Проектируем метамодель
Главы о метамодели (14-18) рассказали, как работают типы. Эта глава о том, когда и как их строить — проектировать виды, отражающие соглашения вашей организации, кодировать предметные требования и выбирать правильный уровень строгости.
Аудитория — платформенные команды, авторы видов и все, кто оказался в положении «вот так мы моделируем X в нашей организации» по отношению к другим командам. Если вы только потребляете виды стандартной библиотеки, пропустите главу — переходите к главе 30 или закройте книгу.
Мы построим метамодель для финтех-организации: карточные процессоры, сервисы леджера, сервисы с обязательным аудитом, регулируемые вебхуки. К концу у вас будет ~6 деклараций типов и ясное понимание, как принимать эти решения для своей предметной области.
Когда тянуться к метамодели
Если каждая команда пишет одни и те же пять меток и одни и те же три поля на каждом микросервисе, у вас есть кандидат в метамодель. Конкретно:
- Вы повторяете поля. У каждого платёжного сервиса есть
ext.runbook_url,ext.processor_vendor,security.contact. Объявите видpayment_service, требующий их. - Вы повторяете метки. У каждого аналитического сервиса
domain: Analytics,security.zone: Internal,data.classification: pii. Объявите видanalytics_service, каскадирующий их. - У вас есть инварианты, о которых забывают. Каждая внешняя интеграция должна иметь поставщика и URL контракта.
external_systemиз стандартной библиотеки уже это требует; ваш проект может делать то же самое для предметных инвариантов. - Вам нужен словарь, соответствующий бизнесу. «Карточный процессор» читается лучше, чем «микросервис, являющийся external_system в области PCI с URL поставщика». Типизируйте это.
Если ничего из перечисленного не подходит, видов стандартной библиотеки достаточно.
Проектирование цепочки типов
Начните с базового вида из стандартной библиотеки, затем добавляйте слои ограничений. Для финтех-примера:
service (стандартная библиотека) ↓payment_service (наш вид: общие требования PCI / runbook / контакт) ↓card_processor (наш вид: специфичные поля для исходящего карточного поставщика) ↓stripe_processor (вид-подсказка для экземпляра: конкретно vendor=Stripe)Три слоя. Каждый добавляет один пакет фактов.
Два принципа:
- Каждый слой должен отвечать на один вопрос.
payment_serviceотвечает: «что верно для каждого модуля в области PCI?»card_processorотвечает: «что верно конкретно для каждого карточного процессора?»stripe_processorедва ли вид — это опциональный сахар. - Не вкладывайте глубже трёх-четырёх уровней. Дальше читатели не удержат цепочку в голове. Если ваша концептуальная иерархия действительно пятислойная, подумайте, не должны ли некоторые из слоёв быть метками.
Базовый вид сервиса
export type service payment_service { required cascade team required ext.runbook_url required security.contact
labels { security.zone: PCI compliance.regime.pci: true }
"Микросервис в области PCI. Обязательные URL runbook, контакт и команда-владелец."}Это добавляет три требования поверх service (который уже требовал team и labels.domain):
- URL runbook.
- Контакт по безопасности.
- Две метки PCI каскадируют автоматически.
Теперь любой экземпляр:
payment_service PaymentAuthorizer { team: Payments ext.runbook_url: "https://wiki.acme.com/auth-runbook" security.contact: "compliance@acme.com" labels { domain: Payments // всё ещё обязательно от service }
command Charge}Попробуйте сохранить без ext.runbook_url: валидация падает с «Required field ‘ext.runbook_url’ is not fulfilled and not dropped». Словарь кодирует инвариант.
Вид карточного процессора
Второй слой конкретно для карточного процессора:
export type payment_service card_processor { required ext.processor_vendor required ext.contract.url required ext.webhook_endpoint
"Карточный процессор, общающийся с внешним поставщиком. Требует имя поставщика, URL контракта и URL, который мы выставляем для входящих вебхуков от него."}Почему отдельный вид, а не три поля внутри payment_service? Потому что не каждый платёжный сервис — карточный процессор. Иерархия позволяет payment_service AccountVerification { ... } обойтись без специфичных карточных полей, оставаясь обязательным для PCI.
Экземпляр (предполагая, что external_system Stripe { ... event PaymentEvents } объявлен где-то в пакете, как в главе 27):
card_processor StripeIntegration { team: Payments ext.runbook_url: "https://wiki.acme.com/stripe-runbook" ext.processor_vendor: "Stripe" ext.contract.url: "https://stripe.com/docs/api" ext.webhook_endpoint: "https://api.acme.com/webhooks/stripe" security.contact: "compliance@acme.com"
labels { domain: Payments }
command Charge command Refund
command StripeWebhook { subscribes: Stripe.PaymentEvents }}Вид интерфейса регулируемого вебхука
Обработчик вебхука выше — просто command. Можно лучше: объявить вид, фиксирующий инварианты регулируемого вебхука. (Обязательные пустые слоты на пользовательских видах интерфейсов поддерживаются системой типов; их принудительная проверка на этапе разбора зависит от версии валидатора — перепроверьте, если целитесь в более старый набор инструментов.)
export type command regulated_webhook { required signature.algorithm // hmac-sha256, ecdsa и т.д. required signature.header // HTTP-заголовок с подписью required retention_days // сколько хранится сырой payload
"Входящий вебхук, который должен проверяться по HMAC и логироваться в аудит."}Теперь:
card_processor StripeIntegration { // ... как выше ...
regulated_webhook StripeWebhook { signature.algorithm: "hmac-sha256" signature.header: "Stripe-Signature" retention_days: 365 subscribes: Stripe.PaymentEvents }}Вебхук теперь декларирует свой собственный контракт; ревьюеры видят с одного взгляда, что он проверяется по подписи и хранится год. Добавление нового вебхука без этих трёх полей — ошибка разбора.
Вид хранилища данных
Каскадирование того же паттерна на слой данных:
export type database pci_vault { required cascade team required data.encryption.algorithm required data.retention_policy_url required ext.runbook_url
labels { security.zone: PCI data.classification: pci compliance.regime.pci: true }
"Зашифрованное хранилище в области PCI. Требует алгоритм шифрования, URL документа политики хранения, runbook."}Используется как:
pci_vault PaymentVault { team: Payments data.encryption.algorithm: "aes-256-gcm" data.retention_policy_url: "https://wiki/pci-retention" ext.runbook_url: "https://wiki/vault-runbook"
labels { domain: Payments data.classification: pci // уже в шаблоне — перепроставляем для ясности }
query Read command Write}Где провести черту строгости
Обязательный пустой слот обязателен. Он фиксирует факт. Он также повышает цену объявления экземпляра. Два вопроса, которые стоит задать, прежде чем сделать поле required:
- Сможет ли экземпляр всегда ответить? Если у 80% экземпляров есть URL runbook, а у 20% его честно нет (сервисы на ранней стадии, внутренние инструменты),
requiredслишком строго — это вынуждает ко лжи (ext.runbook_url: "tbd") или к театральномуdrop. Используйте поле с пустым значением по умолчанию и поднимайте предупреждение в скрипте CI. - Достаточно ли высока цена забывания? Отсутствующий URL runbook на платёжном сервисе Tier-1 — это правда плохо. Отсутствующий слоган на сервисе хобби-проекта — нормально.
required— это язык, говорящий «мы не примем отсутствие этого факта». Убедитесь, что цена соответствует.
Метки PCI (security.zone: PCI, compliance.regime.pci: true) — это значения по умолчанию, а не required, потому что они корректны для каждого payment_service по построению. Если у вас когда-нибудь появится не-PCI платёжный сервис, он не должен быть payment_service; он должен быть другим видом.
Именование видов
Несколько заметок от организаций, которые делают это правильно:
- Используйте слово из бизнеса.
payment_service,card_processor,audit_log— а неpci_service_v2. Вид должен читаться так, как говорит команда. - Не перегружайте технические виды. Вид с именем
databaseдолжен быть базой данных. Если ваш вид — это «сервис, владеющий базой данных и выставляющий CRUD над ней», называйте егоcrud_serviceилиaggregate_service, а неdatabase. - Существительные в единственном числе.
card_processor, неcard_processors. Экземпляры — это единичные вещи. - Принято использовать snake_case. Это отличает пользовательские виды от имён экземпляров в PascalCase.
Каскад против append
На уровне типа у вас три режима распространения для каждого поля: локальный (без модификатора), cascade, append. Проектируя вид, думайте о том, что должно растекаться:
cascade team— каждый вложенный модуль наследует команду родителя, если не задаёт свою.cascade widget: arch-payment-service— каждый экземпляр получает тот же визуал по умолчанию; экземпляры могут переопределить.append tags— накопление тегов через вложенные уровни. Родительскиеtags: ["pci"]и дочерниеtags: ["audited"]разрешаются в["pci", "audited"]на потомке.
Метки всегда каскадируют. Поля по умолчанию локальные; на распространение вы соглашаетесь явно.
Версионирование метамодели
Метамодель меняется со временем. Добавление нового required пустого слота ломает существующие экземпляры. Два более безопасных пути:
- Сначала добавьте как значение по умолчанию без значения. Экземпляры, у которых оно уже есть, работают; новые наследуют. Затем прогоните CI-проверку, чтобы убедиться, что у каждого экземпляра теперь есть значение.
- Добавьте параллельный вид.
card_processor_v2существует рядом сcard_processor; команды мигрируют в своём темпе. Старый вид в итогеdropается из метамодели.
Оба работают. Выбирайте по размеру цены миграции.
Разбор диффа: затягиваем метамодель
Вы выкатили payment_service полгода назад без security.contact. Вы понимаете, что он нужен. Просто добавить required security.contact нельзя — каждый существующий экземпляр сломается.
Шаги:
- PR 1. Добавьте
security.contactкак необязательное значение по умолчанию вpayment_service. Влейте. - PR 2. Прогоните CI-проверку: перечислите каждый
payment_serviceбезsecurity.contact. Заведите задачи на команды-владельцы. - PR N. Команды добавляют
security.contactк своим экземплярам. - Финальный PR. Когда у каждого экземпляра он есть, переключите поле в
required security.contact.
Метамодель затянулась с нулём сломанных сборок. Каждый шаг безопасен сам по себе.
Итоги
- Метамодель — это словарь вашей предметной области. Стройте её, когда поля и метки повторяются между экземплярами.
- Слой видов делайте неглубоким (максимум 3-4 уровня), один пакет фактов на слой.
- Обязательные пустые слоты (
required) фиксируют факты на этапе разбора. Используйте их, когда цена забывания велика. - Каскадируйте метки и поля;
appendдля накапливаемых композитов; локально (без модификатора) для значений, специфичных экземпляру. - Затягивайте метамодель постепенно — добавьте значение по умолчанию, мигрируйте экземпляры, затем сделайте обязательным.
- Половина работы — именование. Используйте бизнес-слова; пользовательские виды в snake_case.
Что дальше
Глава 30: Миграция из Sparx / RSM / UML → — последний разобранный пример. Для читателей, приходящих из инструментов корпоративной архитектуры, отображение их концепций на Archlang.