2026-08-20 22:37:44 +03:00
# Модульная система
## Обзор
Модуль - это изолированный каталог под `src/Modules/` с известным контрактом. Система
построен на принципах **Расширяемой платформы** :
- Ядро (`Core/` ) ничего не знает о модулях
- Модули могут зависеть от `Core/` и `Domain/` , но никогда друг от друга (кроме как через объявленные зависимости).
2026-08-21 16:31:01 +03:00
- Любой модуль можно отключить из `config/modules.php` , не прикасаясь к ядру
2026-08-20 22:37:44 +03:00
- Удаление каталога модуля не приводит к фатальным ошибкам
---
## Структура каталогов модулей
2026-08-21 16:31:01 +03:00
The directory name follows the ** `{name}_{hash5}` ** convention, where `hash5` is the
2026-08-20 22:37:44 +03:00
первые 5 символов модуля `hash_id` . Логическое имя модуля (`module.json`
`name` , который никогда не содержит `_` ) всегда разрешается из манифеста — никогда из
базовое имя каталога. Это позволяет двум модулям с **одинаковым именем** работать в разных
2026-08-21 16:31:01 +03:00
каталоги (`watch_2541a` , `watch_9f1c0` ) и установите их без столкновения с файловой системой. То
конфигурация, график зависимостей и пространство имен - все это не соответствует каноническому `name` , поэтому каталог
переименование не требует переноса данных. Каждый модуль **должен ** иметь `hash_id` : загружаемые файлы, которые отправляются
без такового получите новый идентификатор, сгенерированный и записанный в их `module.json` перед размещением,
2026-08-20 22:37:44 +03:00
таким образом, каталог без хэша никогда не создается. Устаревший пустой каталог `Modules/{name}/` из
2026-08-21 16:31:01 +03:00
более старое развертывание по-прежнему считывается, но оно ** автоматически переносится** в `{name}_{hash5}` (генерируя
`hash_id` , если отсутствует) при следующем `console.php status` — макет без хэша удаляется, а не
2026-08-20 22:37:44 +03:00
держал.
```text
src/Modules/my-module_9f1c0/ # {name}_{hash5}; canonical name is "my-module"
├── module.json # Metadata and manifest
├── MyModule.php # Module class (source of truth)
├── MyService.php # Business logic
├── MyController.php # Admin pages (optional)
├── MyCron.php # Cron logic (optional)
├── MyCronJob.php # CLI cron wrapper (optional)
├── database.sql # Master schema — full current CREATE/seed (optional)
├── database_drop.sql # Teardown — DROP every table the module owns (optional)
├── migrations/ # Forward version deltas (optional)
│ └── 1.1.0.sql # Applied only when upgrading a panel past 1.1.0
└── views/ # Page templates (optional)
├── my_page.php
└── my_page_scripts.php
```
2026-08-21 16:31:01 +03:00
Модуль владеет своей схемой через **три роли, которые отражают ядро** (`bin/install/database.sql`
2026-08-20 22:37:44 +03:00
+ `migrations/` ):
|Файл|Роль|Работает на|
| ---- | ---- | ------- |
2026-08-21 16:31:01 +03:00
| `database.sql` | **One** master schema — the full current `CREATE` /seed |новая **установка** |
2026-08-20 22:37:44 +03:00
| `database_drop.sql` |**Одно** удаление — `DROP TABLE` для каждой таблицы, которой владеет модуль|**удалить**|
2026-08-21 16:31:01 +03:00
| `migrations/<semver>.sql` |**Папка** прямых переходов между версиями|**обновить** для версий в `(installed, current]` |
2026-08-20 22:37:44 +03:00
Правила:
- **Новая установка выполняется только `database.sql` **, поэтому она всегда должна соответствовать последней версии.
2026-08-21 16:31:01 +03:00
схема (каждая дельта загнута внутрь). Записанный `installed_version` является водяным знаком —
2026-08-20 22:37:44 +03:00
ошибки никогда не воспроизводятся при новой установке.
2026-08-21 16:31:01 +03:00
- **Дельты направлены только вперед** (`ALTER` /`INSERT` ), названный `<semver>.sql` — демонтаж - это
2026-08-20 22:37:44 +03:00
одинарный `database_drop.sql` , поэтому нет файлов для каждой версии `.down` .
- Сохраняйте дельты ** идемпотентными** (`ADD COLUMN IF NOT EXISTS` , `INSERT IGNORE` ), чтобы повторные запуски были безопасными.
- Модуль без схемы не отправляет ни один из этих файлов. Модуль, работающий только с разницей (нет `database.sql` )
по-прежнему устанавливается путем повторного воспроизведения каждой дельты в ее версии.
---
## модуль.json
```json
{
"name" : "my-module" ,
"hash_id" : "9f1c0b7e4d2a6538c1e0a4b7d6f39e21" ,
"description" : "Short description" ,
"version" : "1.0.0" ,
"requires_core" : ">=2.0" ,
"environment" : "main" ,
"priority" : 0 ,
"dependencies" : [],
"optional_dependencies" : [],
"has_navbar" : false ,
"has_settings" : false
}
```
### Поля манифеста
|Поле|Тип|По умолчанию|Описание|
| ------ | ----- | :---: | ------------ |
2026-08-21 16:31:01 +03:00
| `name` | `string` |—|Каноническое имя модуля (в случае с kebab, без `_` ). Каталог равен `{name}_{hash5}` , но код всегда использует это значение манифеста, а не базовое имя каталога.|
| `hash_id` | `string` |сгенерированный|**Постоянный** идентификатор модуля — случайный 32-разрядный шестнадцатеричный код, генерируемый ОДИН раз и никогда не изменяющийся при изменении версии или переименовании. Его первые 5 символов образуют суффикс каталога `{name}_{hash5}` . Не редактируйте вручную.|
2026-08-20 22:37:44 +03:00
| `description` | `string` | `""` |Удобочитаемое описание|
2026-08-21 16:31:01 +03:00
| `version` | `string` |—|Средняя версия (`1.0.0` )|
2026-08-20 22:37:44 +03:00
| `requires_core` | `string` |—|Минимальная версия ядра (`>=2.0` )|
2026-08-21 16:31:01 +03:00
| `environment` | `string` | `"main"` |`main` , `lb` или `any` |
2026-08-20 22:37:44 +03:00
| `priority` | `int` | `0` |Приоритет загрузки — более высокие нагрузки раньше|
| `dependencies` | `array` | `[]` |Жесткие зависимости; если они недоступны, зависимый объект пропускается (см. ниже)|
| `optional_dependencies` | `array` | `[]` |Мягкие зависимости (загруженные ранее, если они есть)|
| `has_navbar` | `bool` | `false` |Регистрирует ли модуль элементы навигационной панели|
| `has_settings` | `bool` | `false` |Есть ли у модуля страница настроек|
> **`hash_id` — постоянный идентификатор модуля.** Это случайное значение из 32 шестнадцатеричных чисел,
> сгенерированный ** один раз ** и ** никогда** впоследствии не изменявшийся — он должен пережить изменения версий
2026-08-21 16:31:01 +03:00
> и переименовывает (чтобы оно было случайным, а не производным от `name`/`version`). Сгенерируйте его с помощью
2026-08-20 22:37:44 +03:00
> `php -r 'echo bin2hex(random_bytes(16));'` и вставьте его в `module.json`, когда
2026-08-21 16:31:01 +03:00
> создайте новый модуль. Не создавайте его вручную и не используйте повторно другой модуль. Это придает модулям стабильную идентичность, независимую от `name`,
2026-08-20 22:37:44 +03:00
> который является основой для перемещения модулей в отдельные репозитории и для создания
2026-08-21 16:31:01 +03:00
> явный источник обновления для каждого модуля ** - блок манифеста `update` (смотрите ниже).
2026-08-20 22:37:44 +03:00
**Жесткие и мягкие зависимости:**
2026-08-21 16:31:01 +03:00
- `dependencies` — если какой—либо модуль недоступен (отсутствует на диске, отключен или находится в состоянии `failed` ), зависимый модуль ** пропускается** с каскадным предупреждением в журнале (все, что зависит от него, также пропускается). Остальные модули, панель администратора и интерфейс командной строки продолжают работать; единственная неудовлетворенная зависимость больше не прерывает всю загрузку.
2026-08-20 22:37:44 +03:00
- `optional_dependencies` — загружается перед этим модулем, если присутствует, автоматически пропускается, если отсутствует
2026-08-21 16:31:01 +03:00
> **Защита от смещения.** Модуль, от которого зависят все еще включенные модули, не может быть `disabled` передан через панель / `ModuleManager::setState()` - операция отклоняется вместе со списком зависимых объектов (зеркальное отображение защиты `uninstallModule()`). Это предотвращает переход в состояние "`plex` включено, но его зависимость `watch` отключена".
2026-08-20 22:37:44 +03:00
**Приоритет:**
2026-08-21 16:31:01 +03:00
- Сначала при топологической сортировке учитывается график зависимостей, затем в пределах той же группы выполняется сортировка по убыванию `priority` (большее число = загружено ранее), затем по алфавиту
2026-08-20 22:37:44 +03:00
2026-08-21 16:31:01 +03:00
**Источник обновления (блок `update`, необязательный):**
2026-08-20 22:37:44 +03:00
Откуда модуль получает свои обновления. Отсутствует → `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"
}
```
2026-08-21 16:31:01 +03:00
- `source` — `bundled` (с панелью управления), `platform` (хранилище SaaS), `git` (репозитории), `url` (автономный хостинг). Неизвестные значения возвращаются к значению `bundled` .
- `repository` — удаленный git (для `git` ); `slug` — модуль хранилища (для `platform` , по умолчанию используется `name` ); `url` — URL версии/архива (для `url` ); `channel` — `stable` /`beta` (по умолчанию `stable` ).
2026-08-20 22:37:44 +03:00
2026-08-21 16:31:01 +03:00
Блок нормализуется по `ModuleLoader` и выставляется через `ModuleManager::listModules()` . Еженедельный cron (`cron:module_updates` ) проверяет источники `git` /`url` и записывает данные `available_version` , что приводит к нажатию кнопки обновления ** на X** (отображается только при наличии более новой версии). Нажатие кнопки обновления запускает `ModuleManager::updateModuleFromSource()` :
2026-08-20 22:37:44 +03:00
- `bundled` — файлы поступают вместе с панелью; Обновление просто запускает отложенные миграции.
2026-08-21 16:31:01 +03:00
- `platform` — делегировано потоку установки/обновления в магазине (откат + разветвление LB внутри).
- `git` — загружает ресурс выпуска ** `module.tar.gz` ** по тегу == новая версия (md5-проверяется с помощью выпуска `hashes.md5` , если присутствует).
- `url` — перечитывает `version.json` для его `download` (https) + необязательно `md5` .
2026-08-20 22:37:44 +03:00
2026-08-21 16:31:01 +03:00
Для `git` /`url` выбранное значение `module.json` ** `hash_id` должно совпадать с установленным значением ** (идентификация — репозиторий /URL-адрес не может выдавать себя за другой модуль), затем: резервное копирование → замена файлов → миграция → ** откат при любом сбое ** → распространение до фунта стерлингов.
2026-08-20 22:37:44 +03:00
2026-08-21 16:31:01 +03:00
**Стандартный набор и подготовка.** Модули, которые панель устанавливает по умолчанию, перечислены в `config/bundled_modules.php` , а их ключ - в `hash_id` (неизменен при переименовании). На сегодняшний день все модули имеют `bundled` (их файлы находятся в архиве панели). Когда модуль извлекается в свой собственный репозиторий, измените его запись на `git` /`url` /`platform` source — `syncBundledModules()` , затем автоматически извлекает и устанавливает его с помощью `provisionStandardSet()` (это не требуется, пока все находится в комплекте на диске). `ModuleManager::findModuleByHashId()` определяет модуль по его стабильному идентификатору независимо от каталога/имени.
2026-08-20 22:37:44 +03:00
---
## Подинтерфейсы
`ModuleInterface` разбивает площадь поверхности модуля на типизированные субдоговоры:
```text
ModuleInterface
├── ServiceProviderInterface → boot(ServiceContainer)
├── RouteProviderInterface → registerRoutes(Router)
├── CommandProviderInterface → registerCommands(CommandRegistry)
└── NavbarProviderInterface → registerNavbar()
```
2026-08-21 16:31:01 +03:00
`StreamMiddlewareProviderInterface` является **необязательным** — он не является частью `ModuleInterface` .
2026-08-20 22:37:44 +03:00
Реализуйте это только в том случае, если модулю необходимо внедрить себя в потоковый конвейер.
```php
// Optional — not in ModuleInterface
class MyModule implements ModuleInterface , StreamMiddlewareProviderInterface {
public function getStreamMiddleware () : array {
return [ new MyStreamMiddleware ()];
}
}
```
---
## Класс модуля
2026-08-21 16:31:01 +03:00
Extend `BaseModule` — он предоставляет значения по умолчанию без операций для каждого необязательного метода, так что вы можете использовать только
2026-08-20 22:37:44 +03:00
переопределите то, что на самом деле использует модуль. Требуются только `getName()` и `getVersion()` .
```php
<? php
namespace XcVm\Module\MyModule ;
use BaseModule ;
use ServiceContainer ;
use Router ;
use CommandRegistry ;
use NavbarRegistry ;
use NavbarItem ;
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 ) : MyModuleService {
return new MyModuleService ( $c -> get ( 'db' ));
});
}
public function registerRoutes ( Router $router ) : void {
$router -> get ( 'my_page' , [ MyModuleController :: class , 'index' ], [
'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_page' )
-> label ( 'my_module' )
-> permissions ([ 'my_module' ])
-> order ( 60 )
);
}
}
```
> ** Совет:** модулю без маршрутов, элементов навигационной панели и команд CLI требуется только
2026-08-21 16:31:01 +03:00
> `getName()`, `getVersion()` и `boot()`.
> Модуль изолированной подсистемы (его собственная точка входа и bootstrap, например Ministra) обычно
> оставляет `boot()` и `registerRoutes()` унаследованными как не выполняемые операции.
2026-08-20 22:37:44 +03:00
### Метод контракта
|Метод|Интерфейс|Описание|
| ------- | ----------- | ---------- |
| `getName(): string` | `ModuleInterface` |Уникальное имя (соответствует каталогу)|
| `getVersion(): string` | `ModuleInterface` |Версия Semver|
| `boot(ServiceContainer)` | `ServiceProviderInterface` |Регистрация сервисов в контейнере DI|
| `registerRoutes(Router)` | `RouteProviderInterface` |Регистрация HTTP- и API-маршрутов|
| `registerCommands(CommandRegistry)` | `CommandProviderInterface` |Регистрация команд CLI и задач cron|
| `registerNavbar()` | `NavbarProviderInterface` |Регистрация элементов навигационной панели|
| `install(): void` | `ModuleInterface` |Запуск при установке модуля (миграции, начальный запуск)|
| `uninstall(): void` | `ModuleInterface` |Запуск при удалении модуля (очистка)|
> **Важно — версия может храниться в двух местах.** Модуль объявляет свою версию
2026-08-21 16:31:01 +03:00
> **дважды**: поле `"version"` в `module.json` и возвращаемое значение
2026-08-20 22:37:44 +03:00
> `getVersion()` в классе module. **Сохраняйте их идентичными и изменяйте оба перед
> издательский.** Во время выполнения манифест `version` имеет приоритет — установка/обновление
> и водяной знак `installed_version` сначала читается как `module.json`, и только потом возвращается
2026-08-21 16:31:01 +03:00
> to `getVersion()` — so a stale `getVersion()` silently drifts out of sync and is a
2026-08-20 22:37:44 +03:00
> распространенный источник ошибок типа "выполнена /не выполнена неправильная миграция". Если модуль отправляет файл
2026-08-21 16:31:01 +03:00
> migrations, `database.sql` (master schema) and the highest `migrations/<semver>.sql`
2026-08-20 22:37:44 +03:00
> дельта также должна соответствовать этой версии.
---
## PHP пространства имен
2026-08-21 16:31:01 +03:00
Каждый модуль находится в выделенном пространстве имен PHP: _BOS_0}, где _BOS_1} - это
2026-08-20 22:37:44 +03:00
преобразование имени каталога модуля в PascalCase.
```
src/Modules/my-module/ → namespace XcVm\Module\MyModule;
src/Modules/watch/ → namespace XcVm\Module\Watch;
```
2026-08-21 16:31:01 +03:00
В главном файле модуля должно быть объявлено это пространство имен и расширено `BaseModule` :
2026-08-20 22:37:44 +03:00
```php
<? php
namespace XcVm\Module\MyModule ;
use BaseModule ;
use ServiceContainer ;
use Router ;
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 ;
class MyModuleModule extends BaseModule {
public function boot ( ServiceContainer $container ) : void {
$container -> set ( 'my-module.service' , fn () => new MyModuleService ());
}
}
```
**Правила:**
- Имя файла основного класса модуля: `<PascalName>Module.php` — обязательно (соглашение с загрузчиком модулей)
2026-08-21 16:31:01 +03:00
- All other class filenames: `<PascalName><Purpose>.php`
2026-08-20 22:37:44 +03:00
- Добавьте `use ClassName;` для каждого базового класса, на который ссылается ссылка (базовый модуль, ServiceContainer, маршрутизатор и т.д.)
- Никогда не импортируйте классы из других модулей — общайтесь через события или контейнер DI
---
## Оформление контейнеров и сервизов DI
Сервисы регистрируются в `boot()` через `ServiceContainer` . Контейнер поддерживает:
- **`set(id, factory)` ** — отложенный синглтон с помощью вызываемого или прямого значения
- **`factory(id, callable)` ** — новый экземпляр для каждого `get()`
- **`decorate(id, callable, priority)` ** — завершение существующей службы
```php
// Decorate a service (adds behaviour around the original)
$container -> decorate ( 'stream.encoder' , function ( mixed $inner , ServiceContainer $c ) : MyEncoder {
return new MyEncoder ( $inner , $c -> get ( 'settings' ));
}, priority : 20 );
```
Декораторы объединены в цепочки по приоритету (самый высокий и самый внешний). Защищенные сервисы
2026-08-21 16:31:01 +03:00
(`db` , `settings` , `config` , `auth` ) не удается оформить — любая попытка приводит к результату `RuntimeException` .
2026-08-20 22:37:44 +03:00
### Соответствие требованиям стандарта PSR-11
`ServiceContainer` реализует `ContainerInterface` :
```php
public function get ( string $id ) : mixed ; // throws NotFoundException if missing
public function has ( string $id ) : bool ;
```
`NotFoundException` реализует `NotFoundExceptionInterface extends ContainerExceptionInterface` .
---
## События PSR-14
2026-08-21 16:31:01 +03:00
События - это простые классы PHP. Отправляйте их через `EventDispatcher` :
2026-08-20 22:37:44 +03:00
```php
// In any module
EventDispatcher :: dispatch ( new MyEvent ( $payload ));
// Subscribe
EventDispatcher :: listen ( MyEvent :: class , function ( MyEvent $e ) : void {
// handle
}, priority : 10 );
```
**Приоритет** — более высокое целое число = вызывается первым. По умолчанию `0` .
2026-08-21 16:31:01 +03:00
**Останавливаемые события** — продлить `AbstractEvent` и вызвать `$e->stopPropagation()` :
2026-08-20 22:37:44 +03:00
```php
class MyGatingEvent extends AbstractEvent {
public bool $allowed = true ;
}
EventDispatcher :: listen ( MyGatingEvent :: class , function ( MyGatingEvent $e ) : void {
if ( ! $this -> check ()) {
$e -> allowed = false ;
$e -> stopPropagation ();
}
}, priority : 100 );
```
### Встроенные основные события
|Класс события|Когда отправлено|Останавливаемый|
| --------------- | ---------------------- | :-----------: |
| `ModuleLoadedEvent` |После загрузки файла модуля|❌|
| `ModuleBootedEvent` |После вызова `boot()` |❌|
| `PackageInstalledEvent` |После установки marketplace|❌|
| `UserAuthenticatedEvent` |После успешного входа в систему|✅|
| `UserLoggedOutEvent` |После выхода из системы|❌|
| `StreamStartingEvent` |Перед началом трансляции|✅|
| `StreamStartedEvent` |После начала трансляции|❌|
| `StreamStoppedEvent` |После того, как поток прекратился|❌|
| `SettingsChangedEvent` |После сохранения настроек|❌|
---
## Потоковое промежуточное программное обеспечение
Модули могут внедрять промежуточное программное обеспечение в потоковый конвейер, реализуя
`StreamMiddlewareProviderInterface` (отдельно от `ModuleInterface` ):
```php
class MyStreamMiddleware implements StreamMiddlewareInterface {
public function getPriority () : int {
return 50 ;
}
public function handle ( StreamContext $ctx , callable $next ) : StreamContext {
// before — read or set attributes
$ctx -> set ( 'my.key' , 'value' );
$ctx = $next ( $ctx );
// after
return $ctx ;
}
}
```
`StreamContext` - это набор атрибутов (`get` , `set` , `has` , `abort` , `isAborted` ). `StreamPipeline`
2026-08-21 16:31:01 +03:00
выполняет промежуточное программное обеспечение, отсортированное по убыванию `getPriority()` .
2026-08-20 22:37:44 +03:00
### Приоритеты трубопровода
|Диапазон|Владелец|
| ---------- | ----------------- |
| `80– 100` |Ядро (авторизация, разрешение, ограничение подключения)|
| `0– 79` |Модули|
### Зарезервированные слоты на панели навигации
|Родительский узел|Гнезда для модулей|
| ------------------- | ------------------ |
| `management.service_setup` |`order` ≥ 60|
| `management.logs` |`order` ≥ 170|
---
## Включение / выключение модулей
Все обнаруженные модули загружаются по умолчанию. Используйте `src/config/modules.php` для переопределения состояния:
```php
return [
'my-module' => [ 'state' => 'disabled' ], // preferred
// or legacy boolean (still accepted):
'my-module' => [ 'enabled' => false ],
];
```
2026-08-21 16:31:01 +03:00
Доступные значения `state` (подкрепленные перечислением `ModuleState` ):
2026-08-20 22:37:44 +03:00
|Ценность|Значение|
| ----- | ------- |
| `enabled` |Загрузка модуля (по умолчанию)|
| `disabled` |Модуль обнаружен, но пропущен|
2026-08-21 16:31:01 +03:00
| `installing` |Переходное состояние, заданное значением `ModuleManager` во время установки|
2026-08-20 22:37:44 +03:00
| `failed` |Ошибка установки; модуль пропущен (не загружен)|
2026-08-21 16:31:01 +03:00
> **Диагностика панели.** На странице "Модули" отображается желтый значок "Проблема с зависимостями" рядом со статусом модуля, когда требуемая зависимость отсутствует или не включена (например, `plex` означает `Enabled`, а `watch` - `failed`). Во всплывающей подсказке к значку перечислены конкретные проблемы. Это поле `dependency_warnings` вычисляется с помощью `ModuleManager::listModules()`.
2026-08-20 22:37:44 +03:00
Чтобы переопределить класс, разрешенный для модуля:
```php
return [
'my-module' => [ 'class' => 'XcVm\\Module\\MyModuleV2\\MyModuleV2Module' ],
];
```
`config/modules.php` содержит только переопределения. Пустой или отсутствующий файл означает, что все обнаруженные
загружаются модули.
---
## Как работает загрузка
`ModuleLoader` выполняет эти действия при каждом запросе:
1. Сканирование `src/Modules/*/module.json`
2. Применяет переопределения из `config/modules.php`
3. Фильтры по окружающей среде (`main` / `lb` / `any` )
4. Определяет порядок загрузки:
2026-08-21 16:31:01 +03:00
- `pruneUnsatisfiableModules()` удаляет модули, требуемые зависимости которых недоступны (каскадно, с зарегистрированным предупреждением), чтобы загрузка никогда не прерывалась
2026-08-20 22:37:44 +03:00
- Топологическая сортировка (DFS) по графу зависимостей
2026-08-21 16:31:01 +03:00
- В пределах одной и той же группы зависимостей выполните сортировку по убыванию `priority` , затем по алфавиту
- Выдает `RuntimeException` для циклов (циклические зависимости остаются фатальными)
2026-08-20 22:37:44 +03:00
- Отсутствующие необязательные зависимости автоматически пропускаются
2026-08-21 16:31:01 +03:00
5. Resolves class name: `my-module` → FQN `XcVm\Module\MyModule\MyModuleModule`
2026-08-20 22:37:44 +03:00
(kebab-case → PascalCase; может быть переопределен с помощью клавиши `class` в конфигурации)
6. Регистрирует автозагрузчик модуля PSR-4 (сопоставляет `XcVm\Module\<Name>` с каталогом модуля)
7. Создает экземпляр класса module
В веб-контексте:
- `bootAll($container, $router)` → вызовы `boot()` , `registerRoutes()` , `registerNavbar()` ,
и подписывается на события для каждого загруженного модуля
В контексте командной строки:
- `registerAllCommands($registry)` → вызывает `registerCommands()` для каждого загруженного модуля
---
## Marketplace: установка через расширение C
Модули с платформы устанавливаются через `ModuleManager::downloadFromPlatform()` :
```php
$manager -> downloadFromPlatform ( slug : 'my-module' , version : '1.2.0' , apiKey : $key );
```
Под капотом:
2026-08-21 16:31:01 +03:00
1. `XC_VM::module_install($slug, $version, $apiKey)` — Расширение C загружает, расшифровывает и распаковывает файлы
2026-08-20 22:37:44 +03:00
2. `installModule($slug)` — запускает `install()` в модуле
3. `EventDispatcher::dispatch(new PackageInstalledEvent(...))` — отправляет событие
2026-08-21 16:31:01 +03:00
4. `hotReload($slug, $path)` — загружает модуль в текущем запросе **без перезапуска PHP-FPM.**
2026-08-20 22:37:44 +03:00
---
## Изолированные подсистемы
Модуль может быть полностью изолированной подсистемой со своей собственной точкой входа и начальной загрузкой
(например, Ministra). Это ** соглашение**, а не маркерный интерфейс — он остается
2026-08-21 16:31:01 +03:00
обычный модуль `ModuleInterface` /`BaseModule` :
2026-08-20 22:37:44 +03:00
```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)
с отдельным путем начальной загрузки. Он использует общую инфраструктуру (базу данных, кэш, конфигурацию), но
2026-08-21 16:31:01 +03:00
**не** участвует в основных `Router` , `ModuleLoader::bootAll()` или
`NavbarRegistry` . Реализации `boot()` и `registerRoutes()` обычно являются
2026-08-20 22:37:44 +03:00
оставлено как унаследованное бездействие.
---
## Контроллер
```php
class MyController {
protected 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 {
renderUnifiedLayoutHeader ( 'admin' , [ '_TITLE' => 'My Module' ]);
include $this -> viewsPath . '/my_page.php' ;
renderUnifiedLayoutFooter ( 'admin' );
include $this -> viewsPath . '/my_page_scripts.php' ;
}
}
```
|Правило| |
| --------- | -- |
| `__DIR__ . '/views'` |viewsPath — контроллер находится внутри каталога модуля|
2026-08-21 16:31:01 +03:00
|ПОЛУЧАТЬ страницы|вызовите `renderUnifiedLayoutHeader` перед просмотром, `renderUnifiedLayoutFooter` после|
2026-08-20 22:37:44 +03:00
|Действия API|нет макета — возвращаем JSON и выходим|
---
## Задача Cron
2026-08-21 16:31:01 +03:00
**Cron logic** (`MyCron.php` ) — только бизнес-логика, без подключения к CLI.
2026-08-20 22:37:44 +03:00
**Оболочка CronJob** (`MyCronJob.php` ) — реализует `CommandInterface` , использует `CronTrait` :
```php
class MyCronJob implements CommandInterface {
use CronTrait ;
public function getName () : string { return 'cron:my_task' ; }
public function getDescription () : string { return 'Cron: my 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 ());
}
```
Объявите запись crontab, переопределив `getCronEntries()` в классе module:
```php
public function getCronEntries () : array {
return [
'*/5 * * * *' => 'cron:my_task' ,
];
}
```
`ModuleLoader::collectCronEntries()` объединяет записи всех модулей и `StartupCommand` /
2026-08-21 16:31:01 +03:00
`StatusCommand` автоматически записывайте их в системный crontab — никаких изменений в основных файлах не требуется.
2026-08-20 22:37:44 +03:00
**Формат:** ключ = выражение cron, значение = имя консольной команды, зарегистрированное через `registerCommands()` .
---
## Версионные миграции (MigratableInterface)
> **Два механизма, оба аддитивные.** Схема на основе файлов**, описанная в разделе
> [Структура каталогов модулей](#module-directory-structure) (`database.sql` мастер +
2026-08-21 16:31:01 +03:00
> `database_drop.sql` разборка + `migrations/<semver>.sql` дельты) используется по умолчанию для
2026-08-20 22:37:44 +03:00
> простой DDL/seed. `MigratableInterface` ниже приведен программный путь для обновления.
2026-08-21 16:31:01 +03:00
> шаги, требующие логики PHP (повторное заполнение данных, условные изменения). Модуль может использовать
> один из них или оба; `ModuleManager::updateModule()` сначала запускает файл delta, затем
2026-08-20 22:37:44 +03:00
> вызываемые миграции.
Модули, для обновления которых требуется PHP логическая реализация `MigratableInterface` :
```php
namespace XcVm\Module\MyModule ;
use BaseModule ;
use MigratableInterface ;
use ServiceContainer ;
class MyModuleModule extends BaseModule implements MigratableInterface {
public function getMigrations () : array {
return [
'1.1.0' => function () : void {
// runs when upgrading from any version < 1.1.0 to >= 1.1.0
global $db ;
$db -> query ( "ALTER TABLE xc_my_table ADD COLUMN new_col INT DEFAULT 0" );
},
'1.2.0' => function () : void {
// runs when upgrading from < 1.2.0 to >= 1.2.0
},
];
}
}
```
`ModuleManager::updateModule()` считывает `installed_version` из хранилища переопределений, фильтрует
2026-08-21 16:31:01 +03:00
сопоставляет только записи `> fromVersion && <= toVersion` , сортирует по полу и запускает каждую из них.
2026-08-20 22:37:44 +03:00
вызываемый в своей собственной транзакции базы данных. `installModule()` записи `installed_version` после
2026-08-21 16:31:01 +03:00
успешная установка; `uninstallModule()` удаляет ее.
2026-08-20 22:37:44 +03:00
**Основные правила:**
- Ключи - это полустрочные строки (`'1.1.0'` , `'2.0.0'` ) — `version_compare` используется упорядочение
- Каждая миграция выполняется в рамках своей собственной транзакции — сбой откатывает только этот шаг
- `BaseModule` предоставляет значение по умолчанию `getMigrations(): array { return []; }` , поэтому реализация
2026-08-21 16:31:01 +03:00
`MigratableInterface` является необязательным
2026-08-20 22:37:44 +03:00
---
## Composer обнаружение пакета
2026-08-21 16:31:01 +03:00
Модули могут распространяться в виде Composer пакетов с `"type": "xcvm-module"` :
2026-08-20 22:37:44 +03:00
```json
{
"name" : "vendor/my-xcvm-module" ,
"type" : "xcvm-module" ,
"extra" : {
"xcvm" : {
"module-path" : "src"
}
}
}
```
`ModuleLoader` автоматически сканирует `vendor/composer/installed.json` (Composer 1 и 2
2026-08-21 16:31:01 +03:00
форматирует) и обнаруживает все установленные пакеты `xcvm-module` вместе со встроенным
каталог `src/Modules/` . Пакеты дедуплицируются — модуль как в `modules/` , так и в
2026-08-20 22:37:44 +03:00
`vendor/` загружается только один раз.
---
## Контрольный список модулей
- [ ] Создать `src/Modules/<name>/`
2026-08-21 16:31:01 +03:00
- [ ] Добавить `namespace XcVm\Module\<PascalName>;` к каждому файлу класса
2026-08-20 22:37:44 +03:00
- [ ] Создать `module.json` с помощью `name` , `version` , `requires_core` , `priority` , `dependencies` , `optional_dependencies`
2026-08-21 16:31:01 +03:00
- [ ] Поставьте постоянную отметку `hash_id` (`php -r 'echo bin2hex(random_bytes(16));'` ; никогда не пишите ее от руки)
- [ ] Create `<PascalName>Module.php` extending `BaseModule`
- [ ] Укажите версию в ** как ** `module.json` `"version"` , так и `getVersion()` — они должны совпадать (измените обе версии перед публикацией)
2026-08-21 21:56:31 +03:00
- [ ] Реализовать `boot()` для всех служб, предоставляемых модулем
2026-08-20 22:37:44 +03:00
- [ ] Реализовать `registerRoutes()` для конечных точек HTTP/API
2026-08-21 16:31:01 +03:00
- [ ] Внедрить `registerNavbar()` для элементов панели администратора (или оставить пустым)
- [ ] (Если кроны) Создайте `MyCron.php` + `MyCronJob.php` , зарегистрируйтесь в `registerCommands()`
- [ ] (Если crons) Переопределяет `getCronEntries()` в классе модуля (основной файл не изменяется)
- [ ] (Схема If) Отправляет значения `database.sql` (мастер), `database_drop.sql` (демонтаж) и `migrations/<semver>.sql` дельт
2026-08-20 22:37:44 +03:00
- [ ] (При переносе PHP-логики) Реализовать `MigratableInterface::getMigrations()`
- [ ] (Если страницы) Создайте контроллер, используя `renderUnifiedLayoutHeader/Footer`
2026-08-21 16:31:01 +03:00
- [ ] (Если потоковое промежуточное программное обеспечение) Реализовать `StreamMiddlewareProviderInterface` отдельно
2026-08-20 22:37:44 +03:00
- [ ] Проверить: `php -l src/Modules/<name>/<PascalName>Module.php`
2026-08-21 16:31:01 +03:00
- [ ] Verify: `php console.php --list` shows the module's commands
2026-08-20 22:37:44 +03:00
- [ ] Проверьте: удаление каталога модуля не приводит к фатальной ошибке
---
## часто задаваемые вопросы
**Вопрос: Как мне отключить модуль?**
2026-08-21 16:31:01 +03:00
В поле `src/config/modules.php` добавьте `'module-name' => ['state' => 'disabled']` .
2026-08-20 22:37:44 +03:00
Устаревшая форма `'enabled' => false` также принята для обеспечения обратной совместимости.
** Вопрос: Как мне объявить, что мой модуль зависит от другого?**
2026-08-21 16:31:01 +03:00
Используйте `dependencies` в `module.json` для жестких удалений (должно присутствовать) или `optional_dependencies`
2026-08-20 22:37:44 +03:00
для мягкого удаления (загружается раньше вашего, если присутствует, и автоматически пропускается, если отсутствует).
** Вопрос: Могу ли я украсить основную услугу?**
Да — используйте `$container->decorate('service-id', callable, priority)` в `boot()` .
Защищенные сервисы (`db` , `settings` , `config` , `auth` ) не могут быть оформлены.
**Вопрос: Как мне прослушивать основные события?**
2026-08-21 16:31:01 +03:00
Вызовите `EventDispatcher::listen(EventClass::class, callable, priority)` в любом месте после начальной загрузки,
2026-08-20 22:37:44 +03:00
обычно внутри `boot()` или выделенного класса подписчиков.
**Вопрос: Могу ли я отправлять пользовательские события из модуля?**
Да. Создайте простой класс или расширьте `AbstractEvent` и вызовите `EventDispatcher::dispatch(new MyEvent(...))` .
2026-08-21 16:31:01 +03:00
**Вопрос: Для чего используется `StreamMiddlewareProviderInterface`?**
Это позволяет модулю вводить значение `StreamMiddlewareInterface` в конвейер потоковой обработки
2026-08-21 21:56:31 +03:00
без изменения `StreamProcess.php` . При необходимости реализуйте это вместе с `ModuleInterface` .
2026-08-20 22:37:44 +03:00
## Связанные файлы
|Файл|Роль|
| --- | --- |
2026-08-21 16:31:01 +03:00
| `src/Core/Module/ModuleLoader.php` | Discovers, sorts and boots modules; PSR-4 class resolver |
2026-08-20 22:37:44 +03:00
| `src/config/modules.php` |Конфигурация включения модуля / переопределения класса|
| `src/Modules/` |Каталоги модулей|
| `src/Core/Module/Contract/` |Подинтерфейсы модуля|