# Система модулей ## Обзор Модуль — изолированная директория в `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/.sql` | **Папка** форвардных дельт между версиями | **обновление**, для версий в `(installed, current]` | Правила: - **Свежая установка выполняет только `database.sql`**, поэтому он всегда должен отражать ПОСЛЕДНЮЮ схему (все дельты уже влиты). Watermark `installed_version` гарантирует, что дельты не проигрываются повторно на свежей установке. - **Дельты только форвардные** (`ALTER`/`INSERT`), имя `.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 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/.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 Module.php` — обязательно (соглашение ModuleLoader) - Имена остальных файлов: `.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** --- ## Изолированные подсистемы Модуль может быть полностью изолированной подсистемой с собственной точкой входа и bootstrap (как Ministra). Это **соглашение**, а не маркер-интерфейс — он остаётся обычным модулем на `BaseModule`: ```php class MyModule extends BaseModule { public function getName(): string { return 'my-module'; } public function getVersion(): string { return '1.0.0'; } } ``` Изоляция означает, что подсистема запускается через собственную публичную точку входа (например, `my-module/portal.php`, путь относительно `src/` с отдельным bootstrap). Она делит инфраструктуру (БД, кэш, конфиг), но **не** участвует в основном `Router`, `ModuleLoader::bootAll()` и `NavbarRegistry`. --- ## Контроллер (опционально) ```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//` - [ ] Создать `module.json` (name, version, requires_core, priority, optional_dependencies) - [ ] Проставить постоянный `hash_id` (`php -r 'echo bin2hex(random_bytes(16));'`; руками не писать) - [ ] Создать `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/.sql` (дельты) - [ ] (если миграции с PHP-логикой) `implements MigratableInterface` + `getMigrations()` - [ ] Проверить: `php -l src/Modules//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/` | Под-интерфейсы модулей |