Files
XC_VM/docs/ru/development/modules.md
T
Divarion-D f4555e7943 docs: reorganize structure, sync with current code, add missing framework reference
Structure:
- Split development/ (20 files) into development/ (framework reference) and guides/ (how-to)
- Moved 13 how-to files into new guides/ category (auth, cli, error-handling, etc.)
- Moved navbar-rendering.md from system/ into development/
- Deleted empty system/README.md files
- Deleted documentation_gaps.md (all gaps resolved) and redundant info/update.md

Sidebar / navbar:
- Removed all emojis from _sidebar.md (EN + RU) — fixes tree hierarchy rendering
- Removed flag emojis from _navbar.md language switcher
- Removed duplicate entries from the old "System Documentation" section
- Renamed section headers: "Development" -> "Framework Reference" + "Developer Guides"

Content fixes (modules.md EN + RU):
- Updated "Class naming" section: now documents XcVm\Module\{Pascal} namespaces
  (was "global PHP namespace — Until PHP namespaces are introduced")
- Updated main module class example to use namespace declaration + use statements
- Updated class naming in ModuleLoader description to FQN
- Replaced installCrontab() manual crontab instruction with getCronEntries() API
- Updated enable/disable section: added ModuleState enum table, updated config examples
- Fixed FAQ answer for disabling a module

Content fixes (bootstrap-contexts.md EN + RU):
- Replaced CONTEXT_* string constants with BootContext enum cases throughout
- Updated boot() signature: string $context -> BootContext $context
- Updated getContext() return type: ?string -> ?BootContext

New framework reference docs (EN + RU):
- docs/{en,ru}/development/event-system.md: EventDispatcher singleton bridge,
  #[ListensTo] attribute, getEventSubscribers() array API, stoppable events,
  built-in event catalog, custom event authoring, ListensTo attribute reference
- docs/{en,ru}/development/exception-hierarchy.md: full XcVmException tree,
  container vs module subtrees, PSR-11 compliance notes, catch-by-subsystem examples
2026-06-15 18:27:23 +03:00

29 KiB
Raw Blame History

Система модулей

Обзор

Модуль — изолированная директория в src/modules/ с известным контрактом. Удаление модуля не ломает систему — она продолжает работать, деградируя в функциональности.

Система построена на принципах Extensible Platform:

  • Ядро (core/) ничего не знает о модулях
  • Модули расширяют ядро через интерфейсы-контракты
  • Никакой правки файлов ядра, никакого eval, никакого monkey patching
  • Любой модуль отключается через config/modules.php без последствий для ядра

Структура директории модуля

modules/
└── my-module/
    ├── module.json                # Метаданные + поля загрузки
    ├── MyModule.php               # Главный класс (implements ModuleInterface)
    ├── MyService.php              # Сервисы модуля
    ├── MyController.php           # Контроллер (если есть страницы)
    ├── MyCron.php                 # Крон-логика
    ├── MyCronJob.php              # CLI-обёртка (implements CommandInterface)
    ├── MyStreamMiddleware.php     # Stream-middleware (опционально)
    ├── views/
    │   ├── my_page.php
    │   └── my_page_scripts.php
    └── migrations/
        └── 001_create_table.sql

Манифест module.json

{
    "name": "my-module",
    "description": "Краткое описание модуля",
    "version": "1.0.0",
    "requires_core": ">=2.0",
    "environment": "main",
    "dependencies": [],
    "optional_dependencies": [],
    "has_navbar": false,
    "has_settings": false,
    "priority": 0
}

Поля манифеста

Поле Тип По умолчанию Описание
name string — Уникальное имя (совпадает с именем директории, kebab-case)
description string "" Краткое человекочитаемое описание
version string — Semver-версия (1.0.0)
requires_core string — Минимальная версия ядра (>=2.0)
environment string "main" main — основной сервер, lb — load-balancer, any — оба
dependencies array [] Обязательные зависимости: при отсутствии — ошибка загрузки
optional_dependencies array [] Мягкие зависимости: при отсутствии модуль загружается без них
has_navbar bool false Есть ли пункты навбара
has_settings bool false Есть ли страница настроек
priority int 0 Приоритет загрузки: выше значение — раньше загрузится (при топологически равном положении)

Разница между dependencies и optional_dependencies

{
    "dependencies": ["tmdb"],
    "optional_dependencies": ["plex"]
}
  • dependencies: модуль tmdb обязан быть загружен до my-module. Если tmdb отсутствует — loadAll() выбросит RuntimeException.
  • optional_dependencies: если plex присутствует — он загрузится до my-module. Если отсутствует — загрузка продолжается без него.

Приоритет загрузки

При топологически равных позициях (нет зависимости друг от друга), модули с бо́льшим priority загружаются и бутятся первыми.

{ "name": "auth-guard", "priority": 100 }   ← загрузится первым
{ "name": "tmdb",       "priority": 50  }   ← второй
{ "name": "watch",      "priority": 0   }   ← третий (по умолчанию)

При равных priority — алфавитный порядок (детерминированность).


Интерфейсы модуля

ModuleInterface — составной интерфейс, объединяющий 4 суб-интерфейса:

ModuleInterface
├── ServiceProviderInterface    boot(), getEventSubscribers()
├── RouteProviderInterface      registerRoutes()
├── CommandProviderInterface    registerCommands()
└── NavbarProviderInterface     registerNavbar()

+ getName(), getVersion(), install(), uninstall()

Пятый суб-интерфейс — опциональный, не входит в ModuleInterface:

StreamMiddlewareProviderInterface   getStreamMiddleware()

Модуль реализует его дополнительно, если хочет участвовать в стрим-pipeline.


Класс модуля

Файл src/modules/my-module/MyModule.php.

Расширяйте BaseModule — он предоставляет пустые реализации по умолчанию для всех необязательных методов. Обязательны только getName() и getVersion().

<?php
namespace XcVm\Module\MyModule;

use BaseModule;
use ServiceContainer;
use Router;
use CommandRegistry;

class MyModuleModule extends BaseModule {

    public function getName(): string {
        return 'my-module';
    }

    public function getVersion(): string {
        return '1.0.0';
    }

    public function boot(ServiceContainer $container): void {
        $container->set('my-module.service', function (ServiceContainer $c) {
            return new MyModuleService($c->get('db'));
        });
    }

    public function getEventSubscribers(): array {
        return [
            StreamStartedEvent::class => [MyModuleHandler::class, 'onStreamStarted'],
            // С приоритетом: [callable, int]
            UserAuthenticatedEvent::class => [[MyModuleHandler::class, 'onAuth'], 20],
        ];
    }

    public function registerRoutes(Router $router): void {
        $router->get('my-module', [MyModuleController::class, 'index'], [
            'permission' => ['adv', 'my_module'],
        ]);
        $router->api('my_action', [MyModuleController::class, 'apiAction'], [
            'permission' => ['adv', 'my_module'],
        ]);
    }

    public function registerCommands(CommandRegistry $registry): void {
        $registry->register(new MyModuleCronJob());
    }

    public function registerNavbar(): void {
        NavbarRegistry::add((new NavbarItem('management.service_setup.my_module'))
            ->parent('management.service_setup')
            ->url('my_module')
            ->label('my_module')
            ->permissions(['my_module'])
            ->order(60));
    }

    // override install()/uninstall() only if migrations or cleanup are needed
}

Контракт методов

Метод Интерфейс Описание
getName(): string ModuleInterface Уникальное имя (совпадает с директорией)
getVersion(): string ModuleInterface Semver-версия
install(): void ModuleInterface Вызывается при установке из Marketplace
uninstall(): void ModuleInterface Вызывается при удалении
boot(ServiceContainer) ServiceProviderInterface Регистрация сервисов в DI-контейнере
getEventSubscribers(): array ServiceProviderInterface Подписки на типизированные события PSR-14
registerRoutes(Router) RouteProviderInterface HTTP-маршруты и API-экшены
registerCommands(CommandRegistry) CommandProviderInterface Явная регистрация CLI-команд и крон-задач
registerNavbar(): void NavbarProviderInterface Пункты меню в admin navbar

PHP-пространства имён

Каждый модуль живёт в своём PHP-пространстве имён: XcVm\Module\{Pascal}, где {Pascal} — PascalCase-вариант имени директории модуля.

src/modules/my-module/   →  namespace XcVm\Module\MyModule;
src/modules/watch/       →  namespace XcVm\Module\Watch;

Главный файл модуля обязан объявлять это пространство имён и расширять BaseModule:

<?php
namespace XcVm\Module\MyModule;

use BaseModule;
use ServiceContainer;

class MyModuleModule extends BaseModule {
    // ...
}

Все вспомогательные классы в том же модуле разделяют одно пространство имён:

<?php
namespace XcVm\Module\MyModule;

class MyModuleService { /* ... */ }
class MyModuleController { /* ... */ }
class MyModuleCronJob { /* ... */ }

Для каждого используемого класса ядра добавляйте use:

namespace XcVm\Module\MyModule;

use BaseModule;
use ServiceContainer;
use NavbarRegistry;
use NavbarItem;

Правила:

  • Имя файла главного класса: <PascalName>Module.php — обязательно (соглашение ModuleLoader)
  • Имена остальных файлов: <PascalName><Purpose>.php
  • Добавляйте use для каждого класса ядра, на который есть ссылка
  • Никогда не импортируйте классы из других модулей — общайтесь через события или DI-контейнер

DI-контейнер и декорирование сервисов

Регистрация сервисов

public function boot(ServiceContainer $container): void {
    // Ленивая фабрика (singleton)
    $container->set('my-module.service', function (ServiceContainer $c) {
        return new MyService($c->get('db'), $c->get('settings'));
    });

    // Фабричный сервис (новый экземпляр при каждом get)
    $container->factory('my-module.request', function (ServiceContainer $c) {
        return new MyRequest($_GET, $_POST);
    });
}

Декорирование чужих сервисов

Модуль может обернуть любой незащищённый сервис декоратором без правки его кода:

public function boot(ServiceContainer $container): void {
    $container->decorate(
        'stream.service',
        MyLoggingDecorator::class,  // class-string: new Decorator($inner)
        priority: 20
    );

    // Или callable-форма
    $container->decorate('stream.service', function ($inner, ServiceContainer $c) {
        return new MyLoggingDecorator($inner, $c->get('logger'));
    }, priority: 20);
}

Защищённые сервисы — декорировать нельзя: db, settings, config, auth.

Попытка задекорировать защищённый сервис выбросит RuntimeException.

Порядок применения декораторов: наибольший priority = самый внешний слой (вызывается первым).


PSR-14 События

Система событий — типизированные классы, а не строки.

Подписка на события

В getEventSubscribers() возвращайте карту EventClass::class → callable:

public function getEventSubscribers(): array {
    return [
        // Простой callable
        StreamStartedEvent::class  => [MyHandler::class, 'onStreamStarted'],
        StreamStoppedEvent::class  => [MyHandler::class, 'onStreamStopped'],

        // С приоритетом: [callable, int] — больше приоритет = вызывается раньше
        UserAuthenticatedEvent::class => [
            [MyHandler::class, 'onAuth'],
            50
        ],

        // Замыкание
        SettingsChangedEvent::class => function (SettingsChangedEvent $e): void {
            if (in_array('my_setting', $e->changedKeys())) {
                MyCache::flush();
            }
        },
    ];
}

Диспетчеризация событий из модуля

use EventDispatcher;

EventDispatcher::dispatch(new PackageInstalledEvent(
    slug:        'my-module',
    version:     '1.0.0',
    path:        '/path/to/module',
    installedAt: time(),
));

Прерываемые события (StoppableEventInterface)

Если слушатель вызвал $event->stopPropagation(), остальные слушатели не вызываются.

EventDispatcher::listen(StreamStartingEvent::class, function (StreamStartingEvent $e): void {
    if ($this->isBlocked($e)) {
        $e->abort('blocked by my-module');  // специфичен для StreamStartingEvent
        $e->stopPropagation();
    }
});

Встроенные события ядра

Класс события Когда диспетчеризуется Прерываемое
ModuleLoadedEvent После успешной загрузки файла модуля ❌
ModuleBootedEvent После вызова boot() у модуля ❌
PackageInstalledEvent После установки через Marketplace ❌
UserAuthenticatedEvent Успешная аутентификация ❌
UserLoggedOutEvent Выход пользователя ❌
StreamStartingEvent Перед запуском стрима ✅
StreamStartedEvent Стрим успешно запущен ❌
StreamStoppedEvent Стрим остановлен ❌
SettingsChangedEvent Изменение настроек панели ❌

Все типизированные события находятся в src/core/Events/.


Stream Middleware (опционально)

Если модуль хочет участвовать в обработке стрим-запросов, он реализует StreamMiddlewareProviderInterface (не входит в ModuleInterface):

class MyModule implements ModuleInterface, StreamMiddlewareProviderInterface {

    // ... обязательные методы ModuleInterface ...

    public function getStreamMiddleware(): array {
        return [
            new MyAuthMiddleware(),
            new MyTheftDetectionMiddleware(),
        ];
    }
}

Реализация middleware

class MyTheftDetectionMiddleware implements StreamMiddlewareInterface {

    public function handle(StreamContext $ctx, callable $next): StreamContext {
        if ($this->isTheft($ctx)) {
            $ctx->abort('theft detected', 403);
            return $ctx;  // pipeline останавливается
        }

        // Сохранить данные в context
        $ctx->set('my-module.fingerprint', $this->getFingerprint($ctx));

        return $next($ctx);  // передать управление следующему
    }

    public function getPriority(): int {
        return 60;  // core: 80-100, modules: 0-79, terminal: -1
    }
}

Приоритеты в pipeline

Диапазон Кому принадлежит
80–100 Ядро (Auth, Permission, ConnectionLimit)
0–79 Модули
-1 Terminal middleware (финальное выполнение стрима)

StreamContext

// Прочитать параметры запроса
$streamId = $ctx->get('stream_id');
$userId   = $ctx->get('user_id');

// Записать произвольный атрибут (передаётся по цепочке middleware)
$ctx->set('my-module.checked', true);

// Прервать выполнение
$ctx->abort('reason', 403);
if ($ctx->isAborted()) {
    return $ctx;
}

Navbar

Добавление пунктов меню

Метод registerNavbar() вызывается один раз при boot. Используйте NavbarRegistry::add():

public function registerNavbar(): void {
    // Пункт в Service Setup
    NavbarRegistry::add((new NavbarItem('management.service_setup.my_module'))
        ->parent('management.service_setup')
        ->url('my_module')
        ->label('my_module')
        ->permissions(['my_module'])
        ->order(60));

    // Пункт в Logs (megamenu)
    NavbarRegistry::add((new NavbarItem('management.logs.my_module_log'))
        ->parent('management.logs')
        ->url('my_module_logs')
        ->label('', 'My Module Logs')
        ->permissions(['my_module'])
        ->order(170));
}

Зарезервированные слоты для модулей

Родительский узел Слоты для модулей
management.service_setup order ≥ 60
management.logs order ≥ 170
Прочие секции Не зарезервировано, уточняйте с core

Правила

  1. key — уникальный, стабильный, формат section.group.item
  2. parent — должен ссылаться на существующий узел core-дерева
  3. order — позиция внутри одного parent (меньше = выше в списке)
  4. label('key') — переводимый текст, label('', 'Literal') — фиксированный
  5. permissions(['perm']) — видимость по разрешению (OR-логика)
  6. Если нет пунктов меню — оставьте registerNavbar() пустым

Отключение и включение модулей

Добавьте в src/config/modules.php:

return [
    'my-module' => ['state' => 'disabled'],   // предпочтительно
    // или legacy-форма (обратная совместимость):
    'my-module' => ['enabled' => false],
];

Допустимые значения state (enum ModuleState):

Значение Смысл
enabled Модуль загружается и стартует (по умолчанию)
disabled Обнаружен, но пропускается
installing Переходное состояние при установке
failed Установка завершилась ошибкой; пропускается

Файл содержит только overrides. Если пустой или отсутствует — все найденные модули загружаются.

Можно также переопределить класс модуля:

return [
    'my-module' => ['class' => 'XcVm\\Module\\MyModuleCustom\\MyModuleCustomModule'],
];

Как работает загрузка

ModuleLoader::loadAll()
    │
    ├── glob('modules/*/module.json')
    ├── читает overrides из config/modules.php
    ├── фильтрует по environment (main/lb/any)
    ├── readManifest() → normalizes: dependencies, optional_dependencies, priority
    │
    ├── resolveLoadOrder()  — топологическая сортировка DFS
    │   ├── обязательные deps → RuntimeException если отсутствует
    │   ├── optional deps → пропускается если отсутствует
    │   └── при равной позиции: sort по priority desc, затем alphabetically
    │
    └── для каждого модуля в порядке:
        ├── registerModuleAutoloader($path)
        ├── resolveClassName('my-module') → 'MyModule'
        └── new MyModule()

ModuleLoader::bootAll($container, $router, $pipeline)
    ├── (new CoreNavbarProvider())->registerNavbar()   ← core navbar первым
    │
    └── для каждого модуля:
        ├── instanceof ServiceProviderInterface → boot($container)
        │                                       → registerEventSubscribers()
        ├── instanceof StreamMiddlewareProviderInterface → pipeline->pipe(middleware)
        ├── instanceof RouteProviderInterface → registerRoutes($router)
        └── instanceof NavbarProviderInterface → registerNavbar()

Соглашение по имени класса: my-module → FQN XcVm\Module\MyModule\MyModuleModule (kebab-case → PascalCase; можно переопределить через ключ class в конфиге).

Переопределить класс можно через config/modules.php:

return [
    'my-module' => ['class' => 'MyModuleV2'],
];

Marketplace: установка через C-расширение

Модули из платформы устанавливаются через ModuleManager::downloadFromPlatform():

$manager->downloadFromPlatform(slug: 'my-module', version: '1.2.0', apiKey: $key);

Под капотом:

  1. XC_VM::module_install($slug, $version, $apiKey) — C-расширение скачивает, дешифрует и распаковывает модуль
  2. installModule($slug) — запускает install() у модуля
  3. EventDispatcher::dispatch(new PackageInstalledEvent(...)) — диспетчеризует событие
  4. hotReload($slug, $path) — загружает и бутит модуль в текущем запросе без рестарта PHP-FPM

Изолированные подсистемы (BoundaryInterface)

Если модуль является изолированной подсистемой с собственным bootstrap (как Ministra), он реализует BoundaryInterface:

class MyModule extends BaseModule implements BoundaryInterface {

    public function getName(): string { return 'my-module'; }
    public function getVersion(): string { return '1.0.0'; }

    public function getEntryPoint(): string {
        return 'my-module/portal.php';
    }

    public function isIsolated(): bool {
        return true;
    }
}

BoundaryInterface — маркер изоляции. isIsolated() = true означает, что подсистема запускается через собственный entry point с отдельным bootstrap.


Контроллер (опционально)

class MyController {

    private string $viewsPath;

    public function __construct() {
        $this->viewsPath = __DIR__ . '/views';
        require_once MAIN_HOME . 'public/Views/layouts/admin.php';
        require_once MAIN_HOME . 'public/Views/layouts/footer.php';
    }

    public function index(): void {
        $_TITLE = 'My Module';
		renderUnifiedLayoutHeader('admin', ['_TITLE' => $_TITLE]);
        include $this->viewsPath . '/my_page.php';
        renderUnifiedLayoutFooter('admin');
        include $this->viewsPath . '/my_page_scripts.php';
    }

    public function apiAction(): void {
        echo json_encode(['result' => true]);
        exit;
    }
}
Правило
__DIR__ . '/views' viewsPath — контроллер внутри директории модуля
GET-страницы renderUnifiedLayoutHeader до view, renderUnifiedLayoutFooter после
API-экшены Без layout — JSON напрямую

Крон-задача (опционально)

// src/modules/my-module/MyCronJob.php

class MyCronJob implements CommandInterface {
    use CronTrait;

    public function getName(): string    { return 'cron:my_task'; }
    public function getDescription(): string { return 'My module background task'; }

    public function execute(array $rArgs): int {
        if (!$this->assertRunAsXcVm()) { return 1; }

        require INCLUDES_PATH . 'admin.php';
        require_once __DIR__ . '/MyCron.php';

        $this->initCron('XC_VM[MyTask]');
        MyCron::run();

        return 0;
    }
}

Регистрация в модуле:

public function registerCommands(CommandRegistry $registry): void {
    $registry->register(new MyCronJob());
}

Объявить расписание через getCronEntries() в классе модуля:

public function getCronEntries(): array {
    return [
        '*/5 * * * *' => 'cron:my_task',
    ];
}

ModuleLoader::collectCronEntries() агрегирует записи всех модулей, StartupCommand / StatusCommand автоматически записывают их в системный crontab — изменять файлы ядра не нужно.

Формат: ключ = cron-выражение, значение = имя команды из registerCommands().


PSR-11: ContainerInterface

ServiceContainer реализует ContainerInterface:

public function get(string $id): mixed;  // throws NotFoundException если не найден
public function has(string $id): bool;

NotFoundException реализует NotFoundExceptionInterface → ContainerExceptionInterface.

Интерфейсы находятся в src/core/Container/Psr/. Composer не используется — файлы включены в проект напрямую.


Чеклист добавления модуля

  • mkdir -p src/modules/<name>/
  • Создать module.json (name, version, requires_core, priority, optional_dependencies)
  • Создать <Name>Module.php (extends BaseModule)
  • boot() — зарегистрировать сервисы через $container->set()
  • getEventSubscribers() — подписки на типизированные события
  • registerRoutes() — маршруты (или пустой метод)
  • registerNavbar() — пункты меню (или пустой метод)
  • registerCommands() — крон-задачи (или пустой метод)
  • (опц.) implements StreamMiddlewareProviderInterface + getStreamMiddleware()
  • (опц.) Контроллер + views/
  • (опц.) CronJob + регистрация в StartupCommand
  • Проверить: php -l src/modules/<name>/<Name>Module.php
  • Проверить: php console.php --list показывает команды модуля
  • Проверить: удаление директории не вызывает ошибок ядра

FAQ

Q: Как отключить модуль? A: src/config/modules.php → 'my-module' => ['state' => 'disabled']. Legacy-форма 'enabled' => false тоже принимается для обратной совместимости.

Q: Нужна ли регистрация в конфиге для загрузки? A: Нет. ModuleLoader сам находит все модули по modules/*/module.json.

Q: Мой модуль зависит от другого. Как объявить? A: В module.json через dependencies (обязательно) или optional_dependencies (мягко). Предпочитайте выносить общую логику в core/ вместо межмодульных зависимостей.

Q: Как задекорировать сервис другого модуля? A: $container->decorate('service.id', MyDecorator::class, priority: 10) в своём boot().

Q: Как подписаться на событие с приоритетом? A: [EventClass::class => [[MyHandler::class, 'method'], 50]] в getEventSubscribers(). Можно также вызвать EventDispatcher::listen() напрямую.

Q: Как модуль получает $db? A: $db = $container->get('db') в boot(). Прямой global $db — устарело.

Q: Как модуль получает настройки? A: $settings = $container->get('settings') или SettingsManager::getAll()['key'].

Q: Почему мой middleware не вызывается? A: Проверьте, что модуль реализует StreamMiddlewareProviderInterface (не ModuleInterface — это разные контракты). bootAll() должен быть вызван с $pipeline аргументом.

Q: Мой модуль MAIN-only — что делать? A: Ничего. Все модули MAIN-only по умолчанию — modules/ не входит в LB_DIRS. Для LB используйте "environment": "lb" или "any".