Files
XC_VM/docs/ru/development/modules.md
T
Divarion_D 2466ddfc3e chore(tools): remove orphaned gen-module-hashes.php
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.
2026-08-07 20:49:36 +03:00

854 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Система модулей
## Обзор
Модуль — изолированная директория в `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`**, поэтому он всегда должен отражать
ПОСЛЕДНЮЮ схему (все дельты уже влиты). Watermark `installed_version` гарантирует, что
дельты не проигрываются повторно на свежей установке.
- **Дельты только форвардные** (`ALTER`/`INSERT`), имя `<semver>.sql` — удаление одно
(`database_drop.sql`), поэтому пофайловых `.down` больше нет.
- Держите дельты **идемпотентными** (`ADD COLUMN IF NOT EXISTS`, `INSERT IGNORE`).
- Модуль без схемы не поставляет эти файлы. Модуль только с дельтами (без `database.sql`)
всё равно установится, проиграв все дельты ≤ своей версии.
---
## Манифест `module.json`
```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`
```json
{
"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` загружаются и бутятся первыми.
```json
{ "name": "auth-guard", "priority": 100 } ← загрузится первым
{ "name": "tmdb", "priority": 50 } ← второй
{ "name": "watch", "priority": 0 } ← третий (по умолчанию)
```
При равных `priority` — алфавитный порядок (детерминированность).
### Источник обновлений (блок `update`, опционально)
Откуда модуль берёт обновления. Отсутствует → `bundled` (файлы приходят с панелью и обновляются вместе с ней).
```json
"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
<?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()` в классе
> модуля. **Держите их одинаковыми и повышайте обе перед публикацией.** В рантайме
> приоритет у версии из манифеста — установка/обновление и watermark
> `installed_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
<?php
namespace XcVm\Module\MyModule;
use BaseModule;
use ServiceContainer;
class MyModuleModule extends BaseModule {
// ...
}
```
Все вспомогательные классы в том же модуле разделяют одно пространство имён:
```php
<?php
namespace XcVm\Module\MyModule;
class MyModuleService { /* ... */ }
class MyModuleController { /* ... */ }
class MyModuleCronJob { /* ... */ }
```
Для каждого используемого класса ядра добавляйте `use`:
```php
namespace XcVm\Module\MyModule;
use BaseModule;
use ServiceContainer;
use NavbarRegistry;
use NavbarItem;
```
**Правила:**
- Имя файла главного класса: `<PascalName>Module.php` — обязательно (соглашение ModuleLoader)
- Имена остальных файлов: `<PascalName><Purpose>.php`
- Добавляйте `use` для каждого класса ядра, на который есть ссылка
- Никогда не импортируйте классы из других модулей — общайтесь через события или DI-контейнер
---
## DI-контейнер и декорирование сервисов
### Регистрация сервисов
```php
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);
});
}
```
### Декорирование чужих сервисов
Модуль может обернуть любой незащищённый сервис декоратором без правки его кода:
```php
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`:
```php
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();
}
},
];
}
```
### Диспетчеризация событий из модуля
```php
use EventDispatcher;
EventDispatcher::dispatch(new PackageInstalledEvent(
slug: 'my-module',
version: '1.0.0',
path: '/path/to/module',
installedAt: time(),
));
```
### Прерываемые события (StoppableEventInterface)
Если слушатель вызвал `$event->stopPropagation()`, остальные слушатели **не вызываются**.
```php
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`):
```php
class MyModule implements ModuleInterface, StreamMiddlewareProviderInterface {
// ... обязательные методы ModuleInterface ...
public function getStreamMiddleware(): array {
return [
new MyAuthMiddleware(),
new MyTheftDetectionMiddleware(),
];
}
}
```
### Реализация middleware
```php
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
```php
// Прочитать параметры запроса
$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()`:
```php
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`:
```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()`.
Можно также переопределить класс модуля:
```php
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`:
```php
return [
'my-module' => ['class' => 'MyModuleV2'],
];
```
---
## Marketplace: установка через C-расширение
Модули из платформы устанавливаются через `ModuleManager::downloadFromPlatform()`:
```php
$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`:
```php
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.
---
## Контроллер (опционально)
```php
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 напрямую |
---
## Крон-задача (опционально)
```php
// 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;
}
}
```
Регистрация в модуле:
```php
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
```
Объявить расписание через `getCronEntries()` в классе модуля:
```php
public function getCronEntries(): array {
return [
'*/5 * * * *' => 'cron:my_task',
];
}
```
`ModuleLoader::collectCronEntries()` агрегирует записи всех модулей, `StartupCommand` /
`StatusCommand` автоматически записывают их в системный crontab — изменять файлы ядра не нужно.
**Формат:** ключ = cron-выражение, значение = имя команды из `registerCommands()`.
---
## PSR-11: ContainerInterface
`ServiceContainer` реализует `ContainerInterface`:
```php
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` (extends `BaseModule`)
- [ ] Задать версию в **обоих** местах — `"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/` | Под-интерфейсы модулей |