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

443 lines
30 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.
# Разработка модуля
Как создать модуль XC_VM: его расположение на диске, манифест `module.json`, контракт класса модуля + метода, пространства имен и его контроллер. О том, как модуль обнаруживается/загружается/распространяется, смотрите в [Жизненный цикл модуля](module-lifecycle.md); о подключаемых к нему подключениях смотрите в [Точках расширения модуля](module-extension-points.md).
## Обзор
Модуль - это изолированный каталог под `src/Modules/` с известным контрактом. Система
is built on **Extensible Platform** principles:
- Ядро (`Core/`) ничего не знает о модулях
- Модули могут зависеть от `Core/` и `Domain/`, но никогда друг от друга (кроме как через объявленные зависимости).
- Любой модуль можно отключить из `config/modules.php`, не прикасаясь к ядру
- Удаление каталога модуля не приводит к фатальным ошибкам
---
## Структура каталогов модулей
Имя каталога соответствует условию **`{name}_{hash5}`**, где `hash5` - это
первые 5 символов модуля `hash_id`. Логическое имя модуля (`module.json`
`name`, который никогда не содержит `_`) всегда разрешается из манифеста — никогда из
базовое имя каталога. Это позволяет двум модулям с **то же имя** работать в разных
каталоги (`watch_2541a`, `watch_9f1c0`) и установите их без столкновения с файловой системой. То
конфигурация, график зависимостей и пространство имен - все это не соответствует каноническому `name`, поэтому каталог
переименование не требует переноса данных. У каждого модуля **должен** есть `hash_id`: загружаемые файлы, которые отправляются
без такового получите новый идентификатор, сгенерированный и записанный в их `module.json` перед размещением,
таким образом, каталог без хэша никогда не создается. Устаревший пустой каталог `Modules/{name}/` из
более старое развертывание все еще считывается, но имеет значение от **автоматическая миграция** до `{name}_{hash5}` (генерируя
`hash_id`, если отсутствует) при следующем `console.php status` — макет без хэша удаляется, а не
держал.
```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
```
Модуль владеет своей схемой через **три роли, отражающие суть** (`bin/install/database.sql`
+ `migrations/`):
|Файл|Роль|Работает на|
| ---- | ---- | ------- |
| `database.sql` | **One** master schema — the full current `CREATE`/seed |свежий **устанавливать**|
| `database_drop.sql` |**Один** удаление — `DROP TABLE` для каждой таблицы, которой владеет модуль| **uninstall** |
| `migrations/<semver>.sql` |**Папка** прямых различий между версиями|**обновление**, для версий в `(installed, current]`|
Правила:
- **Выполняется только новая установка `database.sql`**, поэтому он всегда должен отражать последнюю версию
схема (каждая дельта загнута внутрь). Записанный `installed_version` является водяным знаком —
ошибки никогда не воспроизводятся при новой установке.
- **Дельты направлены только вперед** (`ALTER`/`INSERT`), названный `<semver>.sql` — демонтаж - это
одинарный `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
}
```
### Поля манифеста
|Поле|Тип|По умолчанию|Описание|
| ------ | ----- | :---: | ------------ |
| `name` | `string` |—|Каноническое имя модуля (в случае с kebab, без `_`). Каталог равен `{name}_{hash5}`, но код всегда использует это значение манифеста, а не базовое имя каталога.|
| `hash_id` | `string` |сгенерированный|**Постоянный** идентификатор модуля — случайный 32-разрядный шестнадцатеричный код, генерируемый ОДИН раз и никогда не изменяющийся при изменении версии или переименовании. Его первые 5 символов образуют суффикс каталога `{name}_{hash5}`. Не редактируйте вручную.|
| `description` | `string` | `""` |Удобочитаемое описание|
| `version` | `string` |—|Средняя версия (`1.0.0`)|
| `requires_core` | `string` |—|Минимальная версия ядра (`>=2.0`)|
| `environment` | `string` | `"main"` |`main`, `lb` или `any`|
| `priority` | `int` | `0` |Приоритет загрузки — более высокие нагрузки раньше|
| `dependencies` | `array` | `[]` |Жесткие зависимости; если они недоступны, зависимый объект пропускается (см. ниже)|
| `optional_dependencies` | `array` | `[]` |Мягкие зависимости (загруженные ранее, если они есть)|
| `has_navbar` | `bool` | `false` |Регистрирует ли модуль элементы навигационной панели|
| `has_settings` | `bool` | `false` |Есть ли у модуля страница настроек|
> **`hash_id` — постоянный идентификатор модуля.** Это случайное значение из 32 шестнадцатеричных чисел,
> сгенерированный **однажды** и **никогда** измененный впоследствии — он должен пережить сбои в версии
> и переименовывает (чтобы оно было случайным, а не производным от `name`/`version`). Сгенерируйте его с помощью
> `php -r 'echo bin2hex(random_bytes(16));'` и вставьте его в `module.json`, когда
> создайте новый модуль. Не создавайте его вручную и не используйте повторно другой модуль. Это придает модулям стабильную идентичность, независимую от `name`,
> который является основой для перемещения модулей в отдельные репозитории и для создания
> явный для каждого модуля **источник обновления** - блок манифеста `update` (см. ниже).
**Hard vs soft dependencies:**
- `dependencies` — если какой—либо модуль недоступен (отсутствует на диске, отключен или находится в состоянии `failed`), зависимому модулю присваивается значение **пропущенный** с записанным каскадным предупреждением (все, что зависит от него, также пропускается). Остальные модули, панель администратора и интерфейс командной строки продолжают работать; единственная неудовлетворенная зависимость больше не прерывает всю загрузку.
- `optional_dependencies` — загружается перед этим модулем, если присутствует, автоматически пропускается, если отсутствует
> **Остерегайтесь дрейфа.** Модуль, от которого зависят все еще включенные модули, не может быть `disabled` передан через панель / `ModuleManager::setState()` - операция отклоняется со списком зависимостей (зеркально отображая защиту `uninstallModule()`). Это предотвращает переход в состояние "`plex` включено, но его зависимость от `watch` отключена".
**Priority:**
- Сначала при топологической сортировке учитывается график зависимостей, затем в пределах той же группы выполняется сортировка по убыванию `priority` (большее число = загружено ранее), затем по алфавиту
**Update source (`update` block, optional):**
Откуда модуль получает свои обновления. Отсутствует → `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 (`cron:module_updates`) проверяет источники `git`/`url` и записывает `available_version`, что приводит к нажатию кнопки **Обновить до X** (отображается только при наличии более новой версии). Нажатие кнопки Обновить запускает `ModuleManager::updateModuleFromSource()`:
- `bundled` — файлы поступают вместе с панелью; Обновление просто запускает отложенные миграции.
- `platform` — делегировано потоку установки/обновления в магазине (откат + разветвление LB внутри).
- `git` — загружает ресурс выпуска **`module.tar.gz`** по тегу == новая версия (md5-проверяется с помощью выпуска `hashes.md5`, если присутствует).
- `url` — перечитывает `version.json` для его `download` (https) + необязательно `md5`.
Для `git`/`url` выбранного `module.json` **`hash_id` должно быть равно установленному значению** (идентификация — репозиторий /URL-адрес не может выдавать себя за другой модуль), затем: резервное копирование → замена файлов → перенос → **откат при любом сбое** → распространение в LB.
**Стандартный набор и подготовка.** Модули, которые панель устанавливает по умолчанию, перечислены в `config/bundled_modules.php`, с ключом `hash_id` (неизменны при переименовании). На сегодняшний день все модули имеют `bundled` (их файлы находятся в архиве панели). Когда модуль извлекается в свой собственный репозиторий, измените его запись на `git`/`url`/`platform` source — `syncBundledModules()`, затем автоматически извлекает и устанавливает его с помощью `provisionStandardSet()` (это не требуется, пока все находится в комплекте на диске). `ModuleManager::findModuleByHashId()` определяет модуль по его стабильному идентификатору независимо от каталога/имени.
---
## Подинтерфейсы
`ModuleInterface` разбивает площадь поверхности модуля на типизированные субдоговоры:
```text
ModuleInterface
├── ServiceProviderInterface → boot(ServiceContainer)
├── RouteProviderInterface → registerRoutes(Router)
├── CommandProviderInterface → registerCommands(CommandRegistry)
└── NavbarProviderInterface → registerNavbar()
```
Еще несколько провайдеров являются **необязательный** — НЕ входят в состав `ModuleInterface`. `BaseModule`
реализует их как no-ops (так что переопределяйте только то, что вам нужно) и `ModuleLoader` обнаруживает
каждый через `instanceof`. Смотрите [Точки расширения модуля](module-extension-points.md):
```text
(optional, not in ModuleInterface)
├── StreamMiddlewareProviderInterface → registerStreamMiddleware(StreamPipeline)
├── CronProviderInterface → getCronEntries()
├── TopbarProviderInterface → registerTopbar(TopbarRegistry) · per-page action buttons
├── TableProviderInterface → registerTables(TableRegistry) · serverSide DataTable builders
├── PermissionProviderInterface → registerPermissions(PermissionRegistry) · reseller sub-permissions
└── QuickToolsProviderInterface → registerQuickTools(QuickToolsRegistry) · one-shot maintenance actions
```
Topbar / Table / Permission / QuickTools позволяет модулю полностью управлять своим администратором
след — кнопки, таблицы журналов/отчетов, предоставляемые разрешения и обслуживание
инструменты — вместо тех, что жестко закодированы в ядре.
```php
// Optional — not in ModuleInterface
class MyModule implements ModuleInterface, StreamMiddlewareProviderInterface {
public function getStreamMiddleware(): array {
return [new MyStreamMiddleware()];
}
}
```
---
## Класс модуля
Extend `BaseModule` — он предоставляет значения по умолчанию без операций для каждого необязательного метода, так что вы можете использовать только
переопределите то, что на самом деле использует модуль. Требуются только `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(NavbarRegistry $registry): 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 требуется только
> `getName()`, `getVersion()` и `boot()`.
> Модуль изолированной подсистемы (его собственная точка входа и bootstrap, например Ministra) обычно
> оставляет `boot()` и `registerRoutes()` унаследованными как не выполняемые операции.
### Метод контракта
|Метод|Интерфейс|Описание|
| ------- | ----------- | ---------- |
| `getName(): string` | `ModuleInterface` |Уникальное имя (соответствует каталогу)|
| `getVersion(): string` | `ModuleInterface` |Версия Semver|
| `boot(ServiceContainer)` | `ServiceProviderInterface` |Регистрация сервисов в контейнере DI|
| `registerRoutes(Router)` | `RouteProviderInterface` |Регистрация HTTP- и API-маршрутов|
| `registerCommands(CommandRegistry)` | `CommandProviderInterface` |Регистрация команд CLI и задач cron|
| `registerNavbar(NavbarRegistry $registry)` | `NavbarProviderInterface` |Регистрация элементов навигационной панели|
| `registerTopbar(TopbarRegistry $registry)` |`TopbarProviderInterface` * (необязательно)*|Кнопки на верхней панели для каждой страницы (собственные страницы + добавление на основные страницы)|
| `registerTables(TableRegistry $registry)` |`TableProviderInterface` * (необязательно)*|Серверные сборщики данных для идентификаторов таблиц модуля|
| `registerPermissions(PermissionRegistry $registry)` |`PermissionProviderInterface` * (необязательно)*|Дополнительные ключи доступа реселлера к редактору групп|
| `registerQuickTools(QuickToolsRegistry $registry)` |`QuickToolsProviderInterface` * (необязательно)*|Однократные действия с быстрыми инструментами (кнопка + обработчик)|
| `getCronEntries(): array` |`CronProviderInterface` * (необязательно)*|Строки Crontab, собранные по запуску/статусу|
| `install(): void` | `ModuleInterface` |Запуск при установке модуля (миграции, начальный запуск)|
| `uninstall(): void` | `ModuleInterface` |Запуск при удалении модуля (очистка)|
> **Важно — версия хранится в двух местах.** Модуль объявляет свою версию
> **дважды**: поле `"version"` в `module.json` и возвращаемое значение из
> `getVersion()` в классе module. **Сохраняйте их идентичными и изменяйте оба перед
> издательский.** Во время выполнения манифест `version` имеет приоритет — установка/обновление
> и водяной знак `installed_version` сначала читается как `module.json`, и только потом возвращается
> to `getVersion()` — so a stale `getVersion()` silently drifts out of sync and is a
> распространенный источник ошибок типа "выполнена /не выполнена неправильная миграция". Если модуль отправляет файл
> миграции, `database.sql` (основная схема) и самый высокий `migrations/<semver>.sql`
> дельта также должна соответствовать этой версии.
---
## PHP пространства имен
Каждый модуль находится в выделенном пространстве имен PHP: _BOS_0}, где _BOS_1} - это
преобразование имени каталога модуля в PascalCase.
```
src/Modules/my-module/ → namespace XcVm\Module\MyModule;
src/Modules/watch/ → namespace XcVm\Module\Watch;
```
В главном файле модуля должно быть объявлено это пространство имен и расширено `BaseModule`:
```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());
}
}
```
**Rules:**
- Имя файла основного класса модуля: `<PascalName>Module.php` — обязательно (соглашение с загрузчиком модулей)
- All other class filenames: `<PascalName><Purpose>.php`
- Добавьте `use ClassName;` для каждого базового класса, на который ссылается ссылка (базовый модуль, ServiceContainer, маршрутизатор и т.д.)
- Никогда не импортируйте классы из других модулей — общайтесь через события или контейнер DI
---
## Контроллер
```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 — контроллер находится внутри каталога модуля|
|ПОЛУЧАТЬ страницы|вызовите `renderUnifiedLayoutHeader` перед просмотром, `renderUnifiedLayoutFooter` после|
|Действия API|нет макета — возвращаем JSON и выходим|
---
## Контрольный список модулей
- [ ] Создать `src/Modules/<name>/`
- [ ] Добавить `namespace XcVm\Module\<PascalName>;` в каждый файл класса
- [ ] Создать `module.json` с помощью `name`, `version`, `requires_core`, `priority`, `dependencies`, `optional_dependencies`
- [ ] Поставьте постоянный штамп `hash_id` (`php -r 'echo bin2hex(random_bytes(16));'`; никогда не пишите его от руки)
- [ ] Create `<PascalName>Module.php` extending `BaseModule`
- [ ] Укажите версию в **оба** `module.json` `"version"` и `getVersion()` — они должны совпадать (измените обе версии перед публикацией)
- [ ] Реализовать `boot()` для всех сервисов, предоставляемых модулем
- [ ] Реализовать `registerRoutes()` для конечных точек HTTP/API
- [ ] Внедрить `registerNavbar()` для элементов панели администратора (или оставить пустым)
- [ ] (Если кроны) Создайте `MyCron.php` + `MyCronJob.php`, зарегистрируйтесь в `registerCommands()`
- [ ] (Если crons) Переопределяет `getCronEntries()` в классе модуля (основной файл не изменяется)
- [ ] (Схема If) Отправляет значения `database.sql` (мастер), `database_drop.sql` (демонтаж) и `migrations/<semver>.sql` дельт
- [ ] (При переносе PHP-логики) Реализовать `MigratableInterface::getMigrations()`
- [ ] (Если страницы) Создайте контроллер, используя `renderUnifiedLayoutHeader/Footer`
- [ ] (Если потоковое промежуточное программное обеспечение) Реализовать `StreamMiddlewareProviderInterface` отдельно
- [ ] Проверить: `php -l src/Modules/<name>/<PascalName>Module.php`
- [ ] Verify: `php console.php --list` shows the module's commands
- [ ] Проверьте: удаление каталога модуля не приводит к фатальной ошибке
---
## часто задаваемые вопросы
**Q: How do I disable a module?**
В поле `src/config/modules.php` добавьте `'module-name' => ['state' => 'disabled']`.
Устаревшая форма `'enabled' => false` также принята для обеспечения обратной совместимости.
**Q: How do I declare that my module depends on another?**
Используйте `dependencies` в `module.json` для жестких удалений (должно присутствовать) или `optional_dependencies`
для мягкого удаления (загружается раньше вашего, если присутствует, и автоматически пропускается, если отсутствует).
**Q: Can I decorate a core service?**
Да — используйте `$container->decorate('service-id', callable, priority)` в `boot()`.
Защищенные сервисы (`db`, `settings`, `config`, `auth`) не могут быть оформлены.
**Q: How do I listen to core events?**
Вызовите `EventDispatcher::listen(EventClass::class, callable, priority)` в любом месте после начальной загрузки,
обычно внутри `boot()` или выделенного класса подписчиков.
**Q: Can I dispatch custom events from a module?**
Да. Создайте простой класс или расширьте `AbstractEvent` и вызовите `EventDispatcher::dispatch(new MyEvent(...))`.
**Q: What is `StreamMiddlewareProviderInterface` for?**
Это позволяет модулю вводить значение `StreamMiddlewareInterface` в конвейер потоковой обработки
без изменения `StreamProcess.php`. При необходимости реализуйте его вместе с `ModuleInterface`.
## Связанные файлы
|Файл|Роль|
| --- | --- |
| `src/Core/Module/ModuleLoader.php` | Discovers, sorts and boots modules; PSR-4 class resolver |
| `src/config/modules.php` |Конфигурация включения модуля / переопределения класса|
| `src/Modules/` |Каталоги модулей|
| `src/Core/Module/Contract/` |Подинтерфейсы модуля|