The script was never wired up (no `make module-hashes` target, no CI/Makefile caller). Module hash_id generation now lives in the standalone Module_Template kit; for existing modules, generate inline with `php -r 'echo bin2hex(random_bytes(16));'`. Updated the module-dev docs (en + ru) that referenced the removed script / non-existent `make module-hashes` target to use the one-liner.
41 KiB
Система модулей
Обзор
Модуль — изолированная директория в src/Modules/ с известным контрактом. Удаление модуля не ломает систему — она продолжает работать, деградируя в функциональности.
Система построена на принципах Extensible Platform:
- Ядро (
Core/) ничего не знает о модулях - Модули расширяют ядро через интерфейсы-контракты
- Никакой правки файлов ядра, никакого eval, никакого monkey patching
- Любой модуль отключается через
config/modules.phpбез последствий для ядра
Структура директории модуля
Имя директории — по конвенции {name}_{hash5}, где hash5 — первые 5 символов
hash_id. Логическое имя (module.json name, в нём никогда нет _) всегда берётся из
манифеста, а не из имени папки. Благодаря этому два модуля с одинаковым именем живут в
разных папках (watch_2541a, watch_9f1c0) и ставятся без конфликта на ФС. Конфиг, граф
зависимостей и namespace ключуются по каноничному name, поэтому переименование папки не
требует миграции данных. У модуля обязан быть hash_id: если загружаемый модуль пришёл без
него, id генерируется и записывается в module.json до размещения — папка без хеша не создаётся
никогда. Голая папка Modules/{name}/ из старого развёртывания ещё читается, но автоматически
мигрирует в {name}_{hash5} (с генерацией hash_id, если его нет) при следующем
console.php status — старый формат не сохраняется, а вытесняется.
modules/
└── my-module_9f1c0/ # {name}_{hash5}; каноничное имя — "my-module"
├── module.json # Метаданные + поля загрузки
├── MyModule.php # Главный класс (implements ModuleInterface)
├── MyService.php # Сервисы модуля
├── MyController.php # Контроллер (если есть страницы)
├── MyCron.php # Крон-логика
├── MyCronJob.php # CLI-обёртка (implements CommandInterface)
├── MyStreamMiddleware.php # Stream-middleware (опционально)
├── database.sql # Мастер-схема — полный текущий CREATE/seed (опц.)
├── database_drop.sql # Удаление — DROP всех таблиц модуля (опц.)
├── migrations/ # Дельты между версиями (опц.)
│ └── 1.1.0.sql # Применяется только при апгрейде выше 1.1.0
└── views/
├── my_page.php
└── my_page_scripts.php
Модуль владеет своей схемой через три роли — зеркало ядра (bin/install/database.sql
migrations/):
| Файл | Роль | Когда выполняется |
|---|---|---|
database.sql |
Одна мастер-схема — полный текущий CREATE/seed |
свежая установка |
database_drop.sql |
Один файл удаления — DROP TABLE всех таблиц модуля |
удаление |
migrations/<semver>.sql |
Папка форвардных дельт между версиями | обновление, для версий в (installed, current] |
Правила:
- Свежая установка выполняет только
database.sql, поэтому он всегда должен отражать ПОСЛЕДНЮЮ схему (все дельты уже влиты). Watermarkinstalled_versionгарантирует, что дельты не проигрываются повторно на свежей установке. - Дельты только форвардные (
ALTER/INSERT), имя<semver>.sql— удаление одно (database_drop.sql), поэтому пофайловых.downбольше нет. - Держите дельты идемпотентными (
ADD COLUMN IF NOT EXISTS,INSERT IGNORE). - Модуль без схемы не поставляет эти файлы. Модуль только с дельтами (без
database.sql) всё равно установится, проиграв все дельты ≤ своей версии.
Манифест module.json
{
"name": "my-module",
"hash_id": "9f1c0b7e4d2a6538c1e0a4b7d6f39e21",
"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, без _). Директория — {name}_{hash5}, но код всегда ключуется по этому значению манифеста, а не по имени папки. |
hash_id |
string |
генерируется | Постоянная идентичность модуля — случайный 32-hex, генерируется ОДИН раз и не меняется при смене версии/переименовании. Первые 5 символов образуют суффикс папки {name}_{hash5}. Руками не писать. |
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 |
Приоритет загрузки: выше значение — раньше загрузится (при топологически равном положении) |
hash_id— постоянная идентичность модуля. Случайный 32-hex, генерируется один раз и никогда не меняется — переживает смену версии и переименование (потому случайный, не производный отname/version). Сгенерировать:php -r 'echo bin2hex(random_bytes(16));'и вставить вmodule.jsonпри создании модуля. Руками не писать и не переиспользовать чужой. Даёт стабильную идентичность независимо отname— основа для выноса модулей в отдельные репозитории и явного источника обновлений — блокupdateв манифесте (ниже).
Разница между dependencies и optional_dependencies
{
"dependencies": ["tmdb"],
"optional_dependencies": ["plex"]
}
dependencies: модульtmdbобязан быть загружен доmy-module. Еслиtmdbнедоступен (отсутствует на диске, отключён или в состоянииfailed), тоmy-moduleпропускается с предупреждением в лог — каскадно (всё, что зависит отmy-module, тоже пропустится). Загрузка остальных модулей и работа панели/CLI при этом не прерывается (см. «Как работает загрузка»).optional_dependencies: еслиplexприсутствует — он загрузится доmy-module. Если отсутствует — загрузка продолжается без него.
Защита от рассинхрона. Отключить (
disabled) модуль, от которого зависят включённые модули, через панель/ModuleManager::setState()нельзя — операция будет отклонена с пояснением, какие модули его требуют (по аналогии с запретом удаленияuninstallModule()). Это не даёт создать состояние «plexвключён, а его зависимостьwatchвыключена».
Приоритет загрузки
При топологически равных позициях (нет зависимости друг от друга), модули с бо́льшим priority загружаются и бутятся первыми.
{ "name": "auth-guard", "priority": 100 } ← загрузится первым
{ "name": "tmdb", "priority": 50 } ← второй
{ "name": "watch", "priority": 0 } ← третий (по умолчанию)
При равных priority — алфавитный порядок (детерминированность).
Источник обновлений (блок update, опционально)
Откуда модуль берёт обновления. Отсутствует → bundled (файлы приходят с панелью и обновляются вместе с ней).
"update": {
"source": "bundled | platform | git | url",
"repository": "https://github.com/Vateron-Media/xc_vm-module-watch",
"channel": "stable",
"slug": "watch",
"url": "https://…/version.json"
}
source—bundled(с панелью),platform(SaaS-стор),git(релизы репо),url(самохостинг). Неизвестное значение →bundled.repository— git-remote (дляgit);slug— slug в сторе (дляplatform, по умолчанию =name);url— URL версии/архива (дляurl);channel—stable/beta(по умолчаниюstable).
Блок нормализуется в ModuleLoader и доступен через ModuleManager::listModules(). Еженедельный крон (cron:module_updates) проверяет источники git/url и записывает available_version — по нему показывается кнопка Update to X. Клик по Update вызывает ModuleManager::updateModuleFromSource():
bundled— файлы приходят с панелью; Update просто гоняет ожидающие миграции.platform— делегируется в стор-флоу (внутри откат + рассылка на LB).git— качает релиз-ассетmodule.tar.gzтега == новой версии (md5-верификация черезhashes.md5релиза, если есть).url— перечитываетversion.jsonзаdownload(https) + опц.md5.
Для git/url hash_id из скачанного module.json должен совпасть с установленным (identity pinning — репо/URL не подменит чужой модуль), затем: бэкап → замена файлов → миграции → откат при любой ошибке → рассылка на LB.
Стандартный набор и провизия. Модули, которые панель ставит по умолчанию, перечислены в config/bundled_modules.php, ключ — hash_id (стабилен при переименовании). Сейчас все bundled (файлы в архиве панели). Когда модуль вынесут в отдельный репозиторий — переключаешь его запись на источник git/url/platform, и syncBundledModules() автоматически скачает + установит его через provisionStandardSet() (пока всё bundled на диске — no-op). ModuleManager::findModuleByHashId() находит модуль по стабильному id независимо от директории/имени.
Интерфейсы модуля
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 |
Важно — версия задаётся в двух местах. Модуль объявляет свою версию дважды: поле
"version"вmodule.jsonи возвращаемое значениеgetVersion()в классе модуля. Держите их одинаковыми и повышайте обе перед публикацией. В рантайме приоритет у версии из манифеста — установка/обновление и watermarkinstalled_versionсначала читаютmodule.jsonи лишь потом откатываются кgetVersion(), поэтому устаревшийgetVersion()тихо рассинхронизируется и становится частой причиной багов «не та миграция выполнилась / не выполнилась». Если модуль поставляет файловую схему,database.sql(мастер) и старшая дельтаmigrations/<semver>.sqlтоже должны совпадать с этой версией.
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. Если пустой или отсутствует — все найденные модули загружаются.
Диагностика в панели. На странице Modules рядом со статусом модуля показывается жёлтый бейдж ⚠ Dependency issue, если у модуля есть обязательная зависимость, которая отсутствует или не включена (например,
plexчислитсяEnabled, ноwatchв состоянииfailed). В подсказке бейджа перечислены конкретные проблемы. Это поле (dependency_warnings) вычисляетModuleManager::listModules().
Можно также переопределить класс модуля:
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
│ ├── pruneUnsatisfiableModules() → модули с недоступной обязательной
│ │ зависимостью отбрасываются (каскадно, с предупреждением в лог)
│ ├── 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) - Проставить постоянный
hash_id(php -r 'echo bin2hex(random_bytes(16));'; руками не писать) - Создать
<Name>Module.php(extendsBaseModule) - Задать версию в обоих местах —
"version"вmodule.jsonиgetVersion()— они должны совпадать (повышать обе перед публикацией) boot()— зарегистрировать сервисы через$container->set()getEventSubscribers()— подписки на типизированные событияregisterRoutes()— маршруты (или пустой метод)registerNavbar()— пункты меню (или пустой метод)registerCommands()— крон-задачи (или пустой метод)- (опц.)
implements StreamMiddlewareProviderInterface+getStreamMiddleware() - (опц.) Контроллер + views/
- (опц.) CronJob + регистрация в StartupCommand
- (если своя схема)
database.sql(мастер),database_drop.sql(удаление),migrations/<semver>.sql(дельты) - (если миграции с PHP-логикой)
implements MigratableInterface+getMigrations() - Проверить:
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".
Связанные файлы
| Файл | Роль |
|---|---|
src/Core/Module/ModuleLoader.php |
Обнаружение, сортировка и загрузка модулей; PSR-4-резолвер |
src/config/modules.php |
Конфиг включения / переопределения класса модуля |
src/Modules/ |
Каталоги модулей |
src/Core/Module/Contract/ |
Под-интерфейсы модулей |