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
29 KiB
Система модулей
Обзор
Модуль — изолированная директория в 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 |
Правила
key— уникальный, стабильный, форматsection.group.itemparent— должен ссылаться на существующий узел core-дереваorder— позиция внутри одного parent (меньше = выше в списке)label('key')— переводимый текст,label('', 'Literal')— фиксированныйpermissions(['perm'])— видимость по разрешению (OR-логика)- Если нет пунктов меню — оставьте
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);
Под капотом:
XC_VM::module_install($slug, $version, $apiKey)— C-расширение скачивает, дешифрует и распаковывает модульinstallModule($slug)— запускаетinstall()у модуляEventDispatcher::dispatch(new PackageInstalledEvent(...))— диспетчеризует событие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(extendsBaseModule) 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".