2026-08-27 18:07:43 +03:00
# Точки расширения модуля
Основные точки расширения, к которым подключается модуль: контейнер DI, потоковое промежуточное программное обеспечение, задачи cron, миграции версий и типизированные события. Чтобы создать модуль, смотрите [Создание модуля ](module-authoring.md ); для загрузки/жизненного цикла смотрите [Жизненный цикл модуля ](module-lifecycle.md ).
## Оформление контейнеров и сервизов 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 );
```
Декораторы объединены в цепочки по приоритету (самый высокий и самый внешний). Защищенные сервисы
(`db` , `settings` , `config` , `auth` ) не удается оформить — любая попытка приводит к результату `RuntimeException` .
### Соответствие требованиям стандарта 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
Модули подписываются на типизированные события с помощью атрибута `getEventSubscribers()` или `#[ListensTo]` . Это описано в полном объеме — диспетчеризация, регистрация слушателей, приоритеты, события, которые можно остановить, и встроенный каталог событий - на специальной странице [Система событий ](event-system.md ).
---
## Потоковое промежуточное программное обеспечение
Модули могут внедрять промежуточное программное обеспечение в потоковый конвейер, реализуя
`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`
выполняет промежуточное программное обеспечение, отсортированное по убыванию `getPriority()` .
### Приоритеты трубопровода
|Диапазон|Владелец|
| ---------- | ----------------- |
| `80– 100` |Ядро (авторизация, разрешение, ограничение подключения)|
| `0– 79` |Модули|
### Зарезервированные слоты на панели навигации
|Родительский узел|Гнезда для модулей|
| ------------------- | ------------------ |
| `management.service_setup` |`order` ≥ 60|
2026-09-10 17:31:22 +03:00
| `logs.system` |`order` ≥ 50|
| `profile` |`order` 100– 980|
Журналы - это вкладка верхнего уровня `logs` с подгруппами `logs.connections` ,
`logs.streams` , `logs.system` , `logs.users` — прикрепите журнал работы модуля
в разделе `logs.system` . Присоединение дочернего элемента к родительскому ключу, который не существует
автоматически сбрасывает его, поэтому синхронизируйте эти клавиши с `CoreNavbarProvider` .
---
## Кнопки на верхней панели (`TopbarProviderInterface`)
Значение для каждой страницы **верхняя панель** (основная кнопка действия и связанные с ней инструменты
выпадающий список над страницей) собирается с помощью `XcVm\Core\Util\Topbar` . Основные страницы приходят
из собственного списка Topbar; модуль добавляет свои кнопки через
`TopbarProviderInterface::registerTopbar(TopbarRegistry $registry)` , вызванный в
та же фаза загрузки, что и `registerNavbar()` . `BaseModule` по умолчанию не запускается,
поэтому переопределяйте его только тогда, когда вам нужны кнопки на верхней панели.
A module can do **both** of these, in one `registerTopbar()` :
- **Внедрить кнопки на существующую основную страницу** — передать ключ этой страницы (например,
`movies` ); ваши кнопки будут добавлены после основных.
- **Создайте свою собственную совершенно новую страницу** — передать ключ страницы, который ядру неизвестен
(например, `watch` ); вся верхняя панель для этой страницы берется из вашего модуля.
```php
use XcVm\Core\Module\TopbarRegistry ;
public function registerTopbar ( TopbarRegistry $registry ) : void
{
// add($page, $label, $url = null, $permission = null, $attr = null, $order = 100)
// A page the module owns — first entry becomes the primary button.
$registry -> add ( 'watch' , 'Add Folder' , 'watch_add' , 'folder_watch_add' , null , 10 );
$registry -> add ( 'watch' , 'Settings' , 'settings_watch' , 'folder_watch_settings' , null , 20 );
// JS-only action: no url, carry an onClick via $attr.
$registry -> add ( 'watch' , 'Kill Running' , null , 'folder_watch_settings' , 'onClick="killWatchFolder();"' , 40 );
// Inject a button into an existing CORE page.
$registry -> add ( 'movies' , 'Watch Folder' , 'watch' , 'folder_watch' , null , 200 );
}
```
**Клавиша страницы** равно `AdminHelpers::getPageName()` для страницы, на которой отображается кнопка
— то же значение, с которым совпадает верхняя панель.
**Форма входа** отражает `[url, permission, attr]` ядро:
|Аргумент|Значение|
| --- | --- |
| `$url` | Target page/URL. `null` for a JS-only action (pair with `$attr` ). |
| `$permission` |`adv` дополнительное разрешение для кнопки. `null` = отображается всегда.|
| `$attr` | Raw extra attributes: `onClick="…"` , or a well-known `id="…"` . |
| `$order` |Порядок сортировки **среди записей модуля страницы** (по возрастанию).|
**Оформление заказа и основная кнопка.** На странице сначала появляются основные записи, затем
записи в модуле отсортированы по `$order` . `Topbar::items()` означает первое
разрешение-сохраняющаяся запись в виде кнопки **первичный** ; остальные попадают в поле
выпадающий. На странице, полностью принадлежащей модулю, самая низкая запись-`$order` - это
первичный.
**Фильтрация разрешений.** Каждая запись с ненулевым значением `$permission` удаляется
если только `Authorization::check('adv', $permission)` не пройдет, так что кнопки никогда не протекут
к ролям, на которые не имеют права.
**Хорошо известные идентификаторы действий** в общем случае связаны оболочкой (`footer.php` ) и
управляется ядром, поэтому модулю нужно только выдать идентификатор:
| `id="…"` |Эффект|
| --- | --- |
|`btn-export-csv` / `btn-export-json` |Экспорт отчета — выводится только на страницу журнала/отчета с основным списком **и** с разрешением `backups` .|
| `btn-clear-logs` |Модальный режим очистки журналов - тип журнала берется из карты core `LOG_TYPES` для страницы.|
Повторная регистрация того же самого `(page, label)` переопределяет более раннюю запись
(последние выигрыши), соответствующие `NavbarRegistry` .
---
## Табличные данные (`TableProviderInterface`)
Серверная таблица данных отправляет свои данные `id` в конечную точку администратора `./table`
(`TableController` ). Идентификаторы основных таблиц - это жестко запрограммированный переключатель; модуль служит
свой СОБСТВЕННЫЙ идентификатор таблицы через `TableProviderInterface::registerTables(TableRegistry
$registry)` (та же фаза загрузки, что и у других), поэтому разработчик находится в модуле
вместо core. Когда `./table` получает идентификатор, который не является регистром core, он выглядит так
в реестре. `BaseModule` по умолчанию отправляет сообщение о том, что операции не выполняются.
```php
use XcVm\Core\Module\TableRegistry ;
public function registerTables ( TableRegistry $registry ) : void
{
$registry -> register ( 'watch_output' , [ WatchController :: class , 'tableWatchOutput' ]);
}
```
**Контракт с обработчиком** — `fn(array $return, int $start, int $limit, bool $isApi): array` :
```php
public static function tableWatchOutput ( array $rReturn , int $rStart , int $rLimit , bool $rIsAPI ) : array
{
global $db ; // same access the core handlers use
if ( ! Authorization :: check ( 'adv' , 'folder_watch_output' )) {
return $rReturn ; // empty skeleton = no access
}
// …read RequestManager params, run COUNT + paged SELECT…
$rReturn [ 'recordsTotal' ] = $rTotal ;
$rReturn [ 'recordsFiltered' ] = $rTotal ;
foreach ( $rRows as $rRow ) {
// Return CLEAN, KEYED JSON — never HTML. The view renders every cell.
$rReturn [ 'data' ][] = [ 'id' => ( int ) $rRow [ 'id' ], 'status' => ( int ) $rRow [ 'status' ], /* … */ ];
}
return $rReturn ; // do NOT echo/exit — TableController encodes it
}
```
Правила:
- Обработчик получает скелет ответа (`recordsTotal` , `recordsFiltered` ,
`data` ) и возвращает его заполненным. Оно должно быть **нет** `echo` или `exit` —
`TableController` JSON - кодирует возвращаемый массив.
- Возвращает **чистые строки JSON с ключами — без встроенного сервером HTML** . Значки статуса,
кнопки действий и ссылки отображаются на стороне клиента с помощью представления (то же самое
соглашение, которому следуют основные таблицы), что позволяет исключить представление
контроллер и позволяет ячейкам, зависящим от разрешений, использовать флаги, выдаваемые представлением.
- Для ветки REST API (`$isApi` ) повторное использование
`TableController::filterRow($row, $show, $hide)` для столбца включить/исключить.
- Данные ajax `d.id` в представлении должны совпадать с зарегистрированным идентификатором.
---
## Разрешения торгового посредника (`PermissionProviderInterface`)
Каталог дополнительных разрешений для реселлеров редактора группы
(`XcVm\Core\Reference\PermissionReference` ) - это основной список. Модуль добавляет свой
СОБСТВЕННЫЕ ключи доступа через `PermissionProviderInterface::registerPermissions(Регистрация разрешений
$registry)" (та же фаза загрузки), таким образом, модуль владеет разрешениями, на которые он ссылается,
вместо того, чтобы они были жестко запрограммированы в core. Ключи объединяются после списка core.
` ``php
use XcVm\Core\Module\PermissionRegistry;
public function registerPermissions(PermissionRegistry $registry): void
{
$registry->add('folder_watch');
$registry->add('folder_watch_output');
}
` ``
Каждая клавиша отображается в редакторе с метками из переводчика — добавить
` permission_<key>` и ` permission_<key>_text` языковых записей. Маршруты перехода,
элементы навигационной и верхней панелей на ключе точно такие же, как и при использовании основного разрешения
(` Authorization::check('adv', 'folder_watch')`); принудительное выполнение считывает сохраненный
групповые разрешения и не зависит от того, где объявлен ключ.
> **Владение сквозной таблицей модулей.** Журнал/таблица данных модуля полностью соответствует
> модуль: строит свои строки с помощью ` TableProviderInterface` (чистый JSON) и сохраняет
> его бухгалтерия также удаляется / очищается / импортируется в модуле — expose module
> ` ->api(...)` направляет действия в строке и реагирует на ядро **событие** (например
> ` VodImportedEvent`) с помощью ` #[ListensTo]` вместо записи таблицы в ядро
> непосредственно. Ядро никогда не должно быть ` DELETE`/` UPDATE`/` TRUNCATE` таблицей, принадлежащей модулю
> (после удаления модуля он может исчезнуть).
---
## Быстрые инструменты (` QuickToolsProviderInterface`)
Страница "Быстрые инструменты администратора" представляет собой набор одноразовых кнопок обслуживания; каждая из них содержит
его ключ равен ` post.php?action=quick_tools`, который запускает действие сопоставления. Оба
список кнопок и обработчики являются основными. Модуль добавляет свой собственный инструмент — кнопку
**и** действие — через интерфейс quicktoolsprovider::registerQuickTools(QuickToolsRegistry).
$реестр)` .
```php
use XcVm\Core\Module\QuickToolsRegistry ;
public function registerQuickTools ( QuickToolsRegistry $registry ) : void
{
// add($group, $key, $label, $handler)
$registry -> add ( 'logs' , 'clear_watch_logs' , 'clear_watch_logs' , static function () : void {
WatchService :: clearAllLogs (); // do the work; query via global $db
});
}
```
- `$group` - существующая клавиша табуляции (`streams` , `lines` , `logs` , `general` , ...) —
к нему добавляется инструмент — или новый ключ, отображаемый в виде новой вкладки с
общий значок и `$group` в качестве его метки-ключа.
- `$label` - это клавиша перевода для кнопки.
- `$handler` (`fn(): void` ) выполняет действие и должен **нет** повторить/завершить —
`post.php` выдает стандартный JSON-файл `{result:true}` success после его запуска.
2026-08-27 18:07:43 +03:00
---
## Задача Cron
**Логика Cron** (`MyCron.php` ) — только бизнес-логика, без подключения к интерфейсу командной строки.
**Обертка от 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` /
`StatusCommand` автоматически записывайте их в системный crontab — никаких изменений в основных файлах не требуется.
**Формат:** ключ = выражение cron, значение = имя консольной команды, зарегистрированное с помощью `registerCommands()` .
---
## Версионные миграции (MigratableInterface)
> **Два механизма, оба аддитивные.** **файловая схема**, описанный в разделе
> [Структура каталогов модулей](module-authoring.md#module-directory-structure) (`database.sql` мастер +
> `database_drop.sql` разборка + `migrations/<semver>.sql` дельты) используется по умолчанию для
2026-09-10 17:31:22 +03:00
> обычный DDL/seed. `MigratableInterface` ниже приведен путь **программный** для обновления
2026-08-27 18:07:43 +03:00
> шаги, требующие логики PHP (повторное заполнение данных, условные изменения). Модуль может использовать
> один из них или оба; `ModuleManager::updateModule()` сначала запускает файл delta, затем
> вызываемые миграции.
Модули, для обновления которых требуется 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` из хранилища переопределений, фильтрует
сопоставляет только записи `> fromVersion && <= toVersion` , сортирует по полу и запускает каждую из них.
вызываемый в своей собственной транзакции базы данных. `installModule()` записи `installed_version` после
успешная установка; `uninstallModule()` удаляет ее.
**Key rules:**
- Ключи - это полустрочные строки (`'1.1.0'` , `'2.0.0'` ) — `version_compare` используется упорядочение
- Каждая миграция выполняется в рамках своей собственной транзакции — сбой откатывает только этот шаг
- `BaseModule` предоставляет значение по умолчанию `getMigrations(): array { return []; }` , поэтому реализация
`MigratableInterface` является необязательным
---