Skip to content

Обмен данными: ядро ↔ модули ↔ компоненты

Статус: зафиксировано. Сводит в одну картину все каналы обмена, разбросанные по трём осям, расширяемости, контракту модуля и графу зависимостей. Единой «супершины» нет намеренно — есть пять узких каналов, каждый под свой тип обмена. Всё, что не входит в эти каналы, — запрещено (главный источник хаоса в зрелых CMS — обход каналов).

Пять каналов обмена

#КаналТип обменаНаправлениеКогда использовать
1Шина событий«случился факт» (fire-and-forget)любой → все подписчикиРеакция на чужое действие: заказ оплачен → выдать цифровой товар, начислить бонусы, отправить чек
2FilterBus (фильтры)«доработай значение» (конвейер с порядком)ядро/модуль → цепочка фильтров → обратноМодификация данных в потоке владельца: скидки пересчитывают корзину, SEO-модуль дописывает мета
3Сервисные контракты (provides)«нужна способность» (вызов по интерфейсу)потребитель → реализация через DIАбстрактная зависимость: платежи вызывают PaymentGateway, не зная, что за ним ЮKassa
4Прямой сервис-вызов (requires)«нужны данные/действие конкретного модуля»зависимый → публичный сервис владельцаЖёсткая зависимость по графу: корзина запрашивает у commerce-pricing эффективную цену
5Очередиасинхронная работаиздатель job → воркерВсё тяжёлое/внешнее: индексация, письма, вебхуки, импорт

Плюс два внешних канала (не для внутримодульного обмена): REST /api/v1 (API-контракт) — для headless-фронтов, кабинетов (зона 3), мобильных приложений; и шина интеграций cms/integrations-bus + вебхуки — для внешних систем. Модуль никогда не дёргает собственный сайт по HTTP — внутри процесса есть каналы 1–5.

Как выбрать канал (алгоритм)

Четыре вопроса — в этом порядке:

  1. Мне нужен результат прямо сейчас, чтобы продолжить? Нет → событие (канал 1); тяжёлую реакцию подписчик сам унесёт в очередь (канал 5).
  2. Я дорабатываю чужое значение в его потоке (цена, мета, состав корзины)? Да → фильтр (канал 2): владелец потока определяет точку и порядок, я встраиваюсь.
  3. Мне нужна способность, а не конкретный модуль (принять платёж, отправить SMS)? Да → provides-контракт (канал 3): завишу от интерфейса, реализацию решает сайт.
  4. Мне нужны данные/действие конкретного модуля, без него я не работаю? Да → requires в манифест и прямой вызов его публичного сервиса (канал 4).

Если ни один пункт не подошёл — скорее всего, обмен не нужен вовсе (переизбыток связей), либо задача решается на стороне владельца данных новым событием/фильтром.

Гарантии: транзакции, доставка, порядок

Правила, без которых каналы превращаются в источник гейзенбагов:

  • события — после коммита (afterCommit): подписчик не должен увидеть факт, которого ещё нет в БД (или который откатится). Событие внутри транзакции = баг;
  • слушатель идемпотентен: очередь гарантирует «хотя бы одну» доставку, не «ровно одну» — повтор job не создаёт второй чек и не начисляет бонусы дважды (ключ идемпотентности по id события/сущности);
  • порядок не гарантирован между событиями разных джобов: подписчик работает от текущего состояния сущности, а не от предполагаемой последовательности событий;
  • сбой подписчика — его проблема: ретраи с backoff, после исчерпания — failed job
    • алерт (cms/health), но издатель и другие подписчики этого не замечают;
  • критичная внешняя отправка (чек в ОФД, вебхук партнёру) — через журнальную запись в своей транзакции + отправку из очереди по журналу (outbox): сбой процесса между коммитом и отправкой не теряет отправку;
  • тестируемость: контракт каждого канала проверяется стандартными средствами — Event::fake (издание), прогон конвейера фильтров, мок provides-контракта; ТЗ модуля перечисляет свои события/фильтры/контракты — это и есть его тестовая поверхность.

Ядро ↔ модуль

Ядро ничего не знает о модулях. Обмен всегда инициируется по контрактам ядра:

  • вниз (ядро → модули): ядро издаёт канонические события (PagePublished, LeadCreated, SettingChanged…) и прогоняет данные через фильтры — модули подписываются/встраиваются. Слушатели изолированы: упавший слушатель логируется и не валит поток ядра;
  • вверх (модуль → ядро): только через регистрацию в реестрах на bootstrap — блоки в BlockRegistry, виджеты в WidgetRegistry, типы полей в FieldTypeRegistry, настройки в settings-store (своя группа), расписание через registerScheduleVia, permissions из манифеста. Модуль не правит таблицы, конфиги и код ядра;
  • данные ядра (страницы, лиды, медиа, пользователи) модуль читает через репозитории / query-сервисы cms/core-contracts — не сырыми запросами в cms_*-таблицы.

Модуль ↔ модуль

Правило выбора канала — по силе связи:

  1. Связи нет (модули друг о друге не знают) → только события/фильтры. Пример: cms/loyalty слушает OrderPaid от cms/commerce-orders — заказы не знают о существовании бонусов;
  2. Связь абстрактная (нужна «какая-то реализация способности») → provides-контракт: интерфейс живёт в cms/core-contracts, реализация резолвится DI-контейнером. Канонический реестр контрактов ведётся в одном месте — стандарт модуля, §2 (payment-gateway, delivery-provider, notification-channel, search-provider, …, geo-provider, import-target); новый контракт вводится только ревизией ядра;
  3. Связь жёсткая (объявлен requires в манифесте) → вызов публичного сервиса модуля-владельца (его API-фасада). Пример: commerce-cartPricingService из commerce-pricing.

Владение данными: каждая таблица принадлежит ровно одному модулю. Чужие таблицы — только чтение и только через сервисы/репозитории владельца (не raw SQL): владелец может менять схему, не ломая соседей. Писать в чужие таблицы нельзя — «запись» в чужой домен выражается событием («предложи факт»), а владелец сам решает, как его применить. FK на чужую сущность допустим только на её первичный id; при выключении владельца строки остаются (деградация, жизненный цикл).

Компонент ↔ модуль

Компоненты (блоки, виджеты, Filament-ресурсы, секции кабинета) — представительства модуля в чужих зонах, а не самостоятельные акторы:

  • блок/виджет объявляется модулем в реестре и рендерится ядром; данные для рендера он получает от сервиса своего модуля (не запросами в БД из шаблона). Модуль выключен → реестр отдаёт fallback-заглушку — страница жива;
  • JSONB блока хранит только контент и ссылки (id сущностей), не бизнес-данные модулей: блок «товары раздела» хранит id раздела и параметры вывода, а список товаров запрашивает у commerce-catalog на рендере (и кешируется тегами);
  • Filament-ресурсы модуля видят чужие сущности через те же сервисы и relation'ы, что и публичный код — без спецдоступа;
  • секции кабинета (зона 3) получают данные через REST /api/v1 своего модуля — кабинет технически внешний потребитель.

Сквозной пример: «заказ оплачен»

Шлюз (внешний) → cms/webhooks-in (подпись, идемпотентность по event_id)
  → PaymentsService::confirm() [канал 4: requires]
  → событие PaymentSucceeded [канал 1]
      ├─ commerce-orders: статус paid → издаёт OrderPaid
      ├─ commerce-receipts: job фискализации в очередь [канал 5]
      ├─ commerce-digital: job выдачи файлов/лицензий [канал 5]
      ├─ loyalty: начисление бонусов (правила)
      ├─ notifications-bus: письмо/telegram клиенту и менеджеру [контракт 3]
      └─ integration-crm: сделка в CRM через integrations-bus [внешний канал]

Ни один из подписчиков не знает о других; отключение любого не ломает остальных; порядок неважен (каждый работает от факта, не от соседа). Где порядок важен (скидки → бонусы → итог для чека) — используется FilterBus с приоритетами внутри одного потока пересчёта корзины, а не цепочка событий.

Запрещено (антипаттерны обмена)

  • прямой SQL в таблицы чужого модуля или ядра (даже SELECT);
  • запись в чужие таблицы/настройки; «подкрутить чужую строку» = событие владельцу;
  • вызов конкретного класса чужого модуля без requires (скрытая зависимость — сломается при выключении);
  • HTTP-вызов собственного сайта из кода модуля;
  • обмен через глобальное состояние (статики, session() между модулями, файлы);
  • бизнес-логика в слушателе события (слушатель — тонкий: валидирует факт и кладёт job).

Перенос типов контента между инсталляциями (dev → prod)

Реализовано волной 22 (урок Directus, api-lessons §11):

  • cms:export <dir> — manifest.json со схемами всех типов контента и каноническим sha256-хешем каждой схемы;
  • cms:import <dir> --dry-run — diff с целевой БД (new/changed/same) + фиксация apply-токена: хеша текущего состояния цели;
  • cms:import <dir> — применяет manifest, отклоняется, если схема цели изменилась после снятия diff (optimistic lock); осознанный обход — --force. Токен одноразовый.

Граница входа на импорте та же, что в админке: Field::fromArray + RegexGuard::assertSafeRules на каждое поле (включая вложенные). Записи (cms_content_entries) переносятся отдельно (bulk-канал JSONL — фаза 3).

Связи

Внутренняя база знаний студии