- Add Step 4a with controller pattern using renderUnifiedLayoutHeader/Footer - Add layout rules table and important notes - Add checklist items for modules with admin pages - Add bootAll() limitation warning - Both EN and RU versions
17 KiB
Система модулей
Обзор
Модуль — изолированная директория в src/modules/ с известным контрактом. Удаление модуля не ломает систему — она продолжает работать, деградируя в функциональности.
Архитектура
modules/
├── my-module/
│ ├── module.json # Метаданные (name, description, version, requires_core)
│ ├── MyModule.php # Источник истины (implements ModuleInterface)
│ ├── MyService.php # Сервисы модуля
│ ├── MyController.php # Контроллер (если есть страницы)
│ ├── MyCron.php # Крон-логика (если есть)
│ ├── MyCronJob.php # CLI-обёртка крона (implements CommandInterface)
│ ├── views/ # Шаблоны страниц
│ │ ├── my_page.php
│ │ └── my_page_scripts.php
│ └── migrations/ # SQL-миграции модуля (если есть)
│ └── 001_create_table.sql
Принципы
| Правило | Описание |
|---|---|
| PHP — источник истины | Всё поведение определяется в классе модуля, не в JSON |
| module.json — только метаданные | name, description, version, requires_core |
| Авто-обнаружение | ModuleLoader сканирует modules/*/module.json — регистрация в конфиге не нужна |
| Изоляция | Модуль зависит от core/ и domain/, но НИКОГДА от других модулей |
| Graceful degradation | Удаление директории модуля не вызывает ошибок |
| Нет обратных зависимостей | Ядро (core/) не знает о существовании модулей |
| DI через контейнер | Сервисы регистрируются в boot(), не через глобалы |
| Явная регистрация команд | Модуль сам регистрирует команды в registerCommands(), без filesystem scanning |
Шаг 1. Создать директорию
mkdir -p src/modules/my-module
Имя директории = имя модуля. Используйте kebab-case: my-module, theft-detection.
Шаг 2. Создать манифест module.json
{
"name": "my-module",
"description": "Краткое описание модуля",
"version": "1.0.0",
"requires_core": ">=2.0"
}
Поля манифеста
| Поле | Тип | Обязательное | Описание |
|---|---|---|---|
name |
string |
✅ | Уникальное имя модуля (совпадает с именем директории) |
description |
string |
⛔ | Краткое человекочитаемое описание модуля |
version |
string |
✅ | Версия в формате semver (1.0.0) |
requires_core |
string |
✅ | Минимальная версия ядра (>=2.0) |
Важно:
module.jsonсодержит только метаданные. Кроны, команды, маршруты, события, страницы — всё определяется в PHP-классе модуля.
Шаг 3. Создать класс модуля
Файл src/modules/my-module/MyModule.php:
<?php
class MyModule implements ModuleInterface {
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', 'MyService');
}
public function registerRoutes(Router $router): void {
$router->get('my-module', [MyController::class, 'index'], [
'permission' => ['adv', 'my_module'],
]);
$router->api('my_action', [MyController::class, 'apiAction'], [
'permission' => ['adv', 'my_module'],
]);
}
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
public function getEventSubscribers(): array {
return [];
}
public function install(): void {
// Создание таблиц, начальных данных и т.д.
}
public function uninstall(): void {
// Очистка данных модуля
}
}
Контракт ModuleInterface
| Метод | Описание |
|---|---|
getName(): string |
Уникальное имя (совпадает с директорией) |
getVersion(): string |
Semver-версия |
boot(ServiceContainer) |
Регистрация сервисов. Вызывается один раз при загрузке |
registerRoutes(Router) |
HTTP-маршруты и API-действия |
registerCommands(CommandRegistry) |
Явная регистрация CLI-команд и крон-задач |
getEventSubscribers(): array |
Подписки на события ядра |
install(): void |
Установка модуля (миграции, начальные данные) |
uninstall(): void |
Удаление данных модуля |
Шаг 4. Автоматическая регистрация
Регистрация в конфиге не нужна. ModuleLoader автоматически обнаруживает все модули из modules/*/module.json.
Для отключения модуля — добавьте в src/config/modules.php:
return [
'my-module' => ['enabled' => false],
];
config/modules.php содержит только overrides. Если файл пуст или отсутствует — все обнаруженные модули загружаются.
Как работает загрузка
ModuleLoader::loadAll()сканируетmodules/*/module.json- Проверяет overrides в
config/modules.php - Определяет класс по конвенции:
my-module→MyModule(kebab-case → PascalCase + Module) - Создаёт экземпляр модуля
В web-контексте (bootstrap.php):
bootAll($container, $router)→ вызываетboot(),registerRoutes(),getEventSubscribers()
⚠️ Текущее ограничение:
ModuleLoader::bootAll()ещё не вызывается во фронт-контроллере. Маршруты модулей пока зарегистрированы статически вpublic/routes/admin.php. Это будет исправлено в будущем обновлении. Подробности:specs/MODULE_SYSTEM_SPEC.md§0.3.
В CLI-контексте (console.php):
registerAllCommands($registry)→ вызываетregisterCommands()у каждого модуля
Шаг 4а. Создать контроллер (опционально)
Если модуль имеет страницы в админке, создайте класс контроллера. Контроллер использует глобальную систему layout через renderUnifiedLayoutHeader() / renderUnifiedLayoutFooter().
Файл src/modules/my-module/MyController.php:
<?php
class MyController {
protected $viewsPath;
protected $layoutsPath;
public function __construct() {
$this->viewsPath = __DIR__ . '/views';
$this->layoutsPath = MAIN_HOME . 'public/Views/layouts/';
require_once $this->layoutsPath . 'admin.php';
require_once $this->layoutsPath . '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 {
// API-действия (POST) — layout не нужен
$action = $_GET['sub'] ?? '';
// ...
echo json_encode(['result' => true]);
exit;
}
}
Правила layout
| Правило | Описание |
|---|---|
| viewsPath | Всегда __DIR__ . '/views' — контроллер уже находится внутри директории модуля |
| layoutsPath | MAIN_HOME . 'public/Views/layouts/' — общий для всех модулей |
| GET-страницы | Обязательно вызвать renderUnifiedLayoutHeader() до и renderUnifiedLayoutFooter() после view |
| API-действия | Без layout — возвращаем JSON напрямую |
| Скрипты | JS модуля загружается через <module>_scripts.php после footer |
Важно: Используйте
__DIR__ . '/views'для viewsPath — неdirname(__DIR__) . '/modules/...'. Файл контроллера уже внутри директории модуля.
renderUnifiedLayoutHeader('admin', [...])иrenderUnifiedLayoutFooter('admin')определены вpublic/Views/layouts/admin.phpиfooter.php. Они извлекают необходимые глобальные переменные ($rSettings,$rUserInfo,$dbи др.) и рендерят общий header/footer админки.
Шаг 5. Добавить крон-задачу (опционально)
5.1 Крон-класс (логика) — в модуле
Файл src/modules/my-module/MyCron.php:
<?php
class MyCron {
public static function run(): void {
$items = Database::query("SELECT * FROM my_table WHERE status = 'pending'");
foreach ($items as $item) {
self::processItem($item);
}
}
private static function processItem(array $item): void {
// Обработка элемента
}
}
5.2 CronJob-обёртка — в директории модуля
Файл src/modules/my-module/MyCronJob.php:
<?php
require_once MAIN_HOME . 'cli/CronTrait.php';
class MyCronJob implements CommandInterface {
use CronTrait;
public function getName(): string {
return 'cron:my_task';
}
public function getDescription(): string {
return 'Cron: описание задачи';
}
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;
}
}
5.3 Регистрация в модуле
Команда регистрируется явно в registerCommands():
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
Важно: Filesystem scanning модулей не используется. Каждый модуль сам знает свои команды и регистрирует их в
registerCommands().
5.4 Добавить в crontab
В src/cli/Commands/StartupCommand.php метод installCrontab() добавьте запись:
$rCrons[] = '*/5 * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:my_task # XC_VM';
Шаг 6. Настройка сборки (Makefile)
Директория modules/ не входит в LB_DIRS — все модули присутствуют только в MAIN-сборках по умолчанию. Файлы модуля (кроны, команды, вьюхи) автоматически исключены из LoadBalancer сборок.
Полные примеры
Минимальный модуль (без кронов, без маршрутов)
Пример: fingerprint, theft-detection, magscan.
modules/my-module/
├── module.json
└── MyModule.php
module.json:
{
"name": "my-module",
"version": "1.0.0",
"requires_core": ">=2.0"
}
MyModule.php — реализует все методы ModuleInterface. Методы без поведения остаются пустыми.
Полный модуль (сервисы + маршруты + команды + события)
Пример: plex, watch.
modules/my-module/
├── module.json
├── MyModule.php
├── MyService.php
├── MyRepository.php
├── MyController.php
├── MyCron.php
├── MyCronJob.php
└── views/
├── my_page.php
└── my_page_scripts.php
Все файлы модуля живут внутри его директории. CronJob-обёртки регистрируются через registerCommands().
Контроллеры используют глобальную систему layout — см. Шаг 4а для паттерна.
Модуль с событиями
public function getEventSubscribers(): array {
return [
'stream.started' => [MyHandler::class, 'onStreamStarted'],
'stream.stopped' => [MyHandler::class, 'onStreamStopped'],
'user.connected' => [MyHandler::class, 'onUserConnected'],
];
}
Чеклист добавления модуля
- Создать директорию
src/modules/<name>/ - Создать
module.json(name,version,requires_core) - Создать
<Name>Module.php(implementsModuleInterface) - (Если есть кроны) Создать
<Name>Cron.php+<Name>CronJob.phpв модуле - (Если есть кроны) Зарегистрировать в
registerCommands() - (Если есть кроны) Добавить в crontab через
StartupCommand - (Если есть страницы) Создать контроллер с
renderUnifiedLayoutHeader/Footer - (Если есть страницы) Создать директорию
views/с шаблонами страниц - (Если есть страницы) Зарегистрировать маршруты в
registerRoutes()(и временно вpublic/routes/admin.php) - Проверить:
php -l src/modules/<name>/<Name>Module.php - Проверить: модуль загружается при
php console.php --list - Проверить: удаление директории модуля не вызывает fatal error
Доступные события ядра
| Событие | Описание | Данные |
|---|---|---|
stream.started |
Стрим запущен | ['stream_id' => int] |
stream.stopped |
Стрим остановлен | ['stream_id' => int] |
user.connected |
Пользователь подключился | ['user_id' => int, 'stream_id' => int] |
cache.rebuilt |
Кэш перестроен | [] |
FAQ
Q: Как отключить модуль?
A: В src/config/modules.php добавьте 'module-name' => ['enabled' => false].
Q: Нужно ли регистрировать модуль в конфиге?
A: Нет. ModuleLoader автоматически обнаруживает все модули из modules/*/module.json. Конфиг нужен только для отключения.
Q: Модуль зависит от другого модуля — как?
A: Не допускайте зависимостей между модулями. Модуль зависит только от core/ и domain/. Если нужна общая функциональность — вынесите в ядро.
Q: Могу ли я использовать $db напрямую?
A: Технически да (через global $db), но архитектурно правильно использовать Database через ServiceContainer или Repository.
Q: Как модуль получает доступ к настройкам?
A: Через SettingsManager::getAll()['my_key']. Ключи настроек модуля хранятся в общей таблице settings.
Q: Мой модуль нужен только на MAIN — что делать?
A: Все модули уже MAIN-only по умолчанию — modules/ не входит в LB_DIRS.