Files
XC_VM/docs/ru/development/module-extension-points.md
T
2026-09-10 17:31:22 +03:00

381 lines
22 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.
# Точки расширения модуля
Основные точки расширения, к которым подключается модуль: контейнер 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|
| `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 после его запуска.
---
## Задача 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` дельты) используется по умолчанию для
> обычный DDL/seed. `MigratableInterface` ниже приведен путь **программный** для обновления
> шаги, требующие логики 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` является необязательным
---