Разработка модуля¶
Как создать модуль XC_VM: его расположение на диске, манифест module.json, контракт класса модуля + метода, пространства имен и его контроллер. О том, как модуль обнаруживается/загружается/распространяется, смотрите в Жизненный цикл модуля; о перехватчиках, к которым он подключается, смотрите в Точках расширения модуля.
Обзор¶
Модуль - это изолированный каталог под 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 — макет без хэша удаляется, а не
держал.
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¶
{
"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.6.1); смотрите Совместимость с ядрами |
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 (файлы отправляются вместе с панелью и обновляются вместе с ней).
"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() определяет модуль по его стабильному идентификатору независимо от каталога/имени.
Совместимость с ядром¶
requires_core - это одно или несколько условий, все из которых должны выполняться, разделенных пробелами
или запятыми: >=, >, <=, <, = (или ==) или !=, за которыми следует вариант. Голый
версия означает >=. Примеры: >=2.6.1, >=2.6 <3.0. Пустой означает любое ядро. Что-нибудь
в противном случае (^, ~, *, диапазоны с -) не могут быть прочитаны и модуль исключается.
Модуль, для которого requires_core это ядро не соответствует:
- не загружен — пропущен с зарегистрированным предупреждением, а вместе с ним и его зависимые элементы (a модуль, скопированный вручную или оставленный в результате отката ядра). На странице "Модули" показано это со значком "Проблема", который указывает причину;
- не установлен и не обновлен — загрузка zip-файла, обновление git/url-адреса или платформы и Отключение LB отклоняется до того, как будет затронута установленная копия. Подсистема балансировки нагрузки, которая core отстает от MAIN и отказывается от модуля самостоятельно; это не влияет на установку MAIN;
- не предлагается в качестве обновления — для источника
gitпроверка обновления показываетmodule.jsonв корень репозитория теге каждого нового выпуска (сначала самый новый, до до 10) и предлагает самую новую версию, которую может запустить это ядро; для исходного кодаurlиспользуетсяrequires_coreвversion.jsonпроверяется таким же образом.
Ночное ядро (2.6.1-dev.N) построено на основе main в качестве предстоящего выпуска, поэтому оно
считается равным 2.6.1.
Сбой requires_core происходит всякий раз, когда модуль начинает использовать базовый API, который использует более старые ядра.
отсутствие.
Подинтерфейсы¶
ModuleInterface разбивает площадь поверхности модуля на типизированные субдоговоры:
ModuleInterface
├── ServiceProviderInterface → boot(ServiceContainer)
├── RouteProviderInterface → registerRoutes(Router)
├── CommandProviderInterface → registerCommands(CommandRegistry)
└── NavbarProviderInterface → registerNavbar()
Еще несколько провайдеров являются необязательный — НЕ входят в состав ModuleInterface. BaseModule
реализует их как no-ops (так что переопределяйте только то, что вам нужно) и ModuleLoader обнаруживает
каждый через instanceof. Смотрите Точки расширения модуля:
(optional, not in ModuleInterface)
├── 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 позволяет модулю полностью управлять своим администратором след — кнопки, таблицы журналов/отчетов, предоставляемые разрешения и обслуживание инструменты — вместо тех, что жестко закодированы в ядре.
Класс модуля¶
Extend BaseModule — он предоставляет значения по умолчанию без операций для каждого необязательного метода, так что вы можете использовать только
переопределите то, что на самом деле использует модуль. Требуются только getName() и getVersion().
<?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, и только потом возвращается togetVersion()— so a stalegetVersion()silently drifts out of sync and is a распространенный источник ошибок типа "выполнена /не выполнена неправильная миграция". Если модуль отправляет файл migrations,database.sql(master schema) and the highestmigrations/<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
namespace XcVm\Module\MyModule;
use BaseModule;
use ServiceContainer;
use Router;
class MyModuleModule extends BaseModule {
// ...
}
Все дополнительные классы в одном модуле используют одно и то же пространство имен:
<?php
namespace XcVm\Module\MyModule;
class MyModuleService { /* ... */ }
class MyModuleController { /* ... */ }
class MyModuleCronJob { /* ... */ }
use классы, на которые вы ссылаетесь:
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
Контроллер¶
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.phpextendingBaseModule - [ ] Укажите версию в оба
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 - [ ] Проверить:
php -l src/Modules/<name>/<PascalName>Module.php - [ ] Verify:
php console.php --listshows 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?
Ничего: оно устарело. Core никогда не запускал конвейер потокового промежуточного программного обеспечения, поэтому интерфейс
сохраняется только для того, чтобы старые модули продолжали загружаться. Для нового вида живого исходного кода используйте
исходный драйвер; чтобы реагировать на изменения потока, прослушивайте события.
Связанные файлы¶
| Файл | Роль |
|---|---|
src/Core/Module/ModuleLoader.php |
Обнаруживает, сортирует и загружает модули; распознаватель классов PSR-4 |
src/config/modules.php |
Конфигурация включения модуля / переопределения класса |
src/Modules/ |
Каталоги модулей |
src/Core/Module/Contract/ |
Подинтерфейсы модуля |