Files
XC_VM/docs/ru/development/modules.md
T
Divarion_D bdbcde97c7 docs: commit generated ru, translate locally before release (not in CI)
CI translation was slow, so move it out of GitHub Actions: docs/ru is now a
committed, generated tree refreshed LOCALLY before a release; CI only builds it.

- pages.yml: drop the setup-python/cache/translate steps — the workflow now just
  installs the build toolchain and runs `mkdocs build --strict` on the committed
  en+ru trees.
- .gitignore: stop ignoring docs/ru (now committed); keep site/ + .docs-cache/.
- Split deps: docs/requirements.txt = build only (mkdocs-material, static-i18n,
  used by CI); tools/docs/requirements.txt = translators (local-only).
- Makefile: docs-venv installs both; docs-build/docs-serve no longer translate
  (translation is the deliberate `make docs-translate` release step).
- updates_checklist.md: new "Regenerate translated documentation" step
  (make docs-translate + docs-build, commit docs/ru with the release commit).
- Commit the generated docs/ru (37 files, translators/yandex).

Rule: edit ONLY docs/en; docs/ru is generated — never hand-edit it.
2026-08-20 22:37:44 +03:00

44 KiB
Raw Blame History

Модульная система

Обзор

Модуль - это изолированный каталог под src/Modules/ с известным контрактом. Система построен на принципах Расширяемой платформы:

  • Ядро (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 Одна главная схема — полная текущая CREATE/начальная новая установка
database_drop.sql Одно удаление — DROP TABLE для каждой таблицы, которой владеет модуль удалить
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.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 manifest (смотрите ниже).

Жесткие и мягкие зависимости:

  • dependencies — если какой—либо модуль недоступен (отсутствует на диске, отключен или находится в состоянии failed), зависимый модуль ** пропускается ** с записанным каскадным предупреждением (все, что зависит от него, также пропускается). Остальные модули, панель администратора и интерфейс командной строки продолжают работать; единственная неудовлетворенная зависимость больше не прерывает всю загрузку.
  • optional_dependencies — загружается перед этим модулем, если присутствует, автоматически пропускается, если отсутствует

** Защита от смещения.** Модуль, от которого зависят все еще включенные модули, не может быть disabled через панель / ModuleManager::setState() - операция отклоняется вместе со списком зависимых объектов (зеркальное отображение защиты uninstallModule()). Это предотвращает переход в состояние "plex включен, но его watch зависимость отключена".

Приоритет:

  • Сначала при топологической сортировке учитывается график зависимостей, затем в пределах той же группы выполняется сортировка по priority по убыванию (большее число = загружено ранее), затем по алфавиту

Источник обновления (update block, необязательно):

Откуда модуль получает свои обновления. Отсутствует → 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 — store 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 источник — syncBundledModules(), затем автоматически извлекает и устанавливает его с помощью provisionStandardSet() (не требуется, пока все находится в комплекте на диске). ModuleManager::findModuleByHashId() определяет модуль по его стабильному идентификатору независимо от каталога/имени.


Подинтерфейсы

ModuleInterface разбивает площадь поверхности модуля на типизированные субдоговоры:

ModuleInterface
├── ServiceProviderInterface   → boot(ServiceContainer)
├── RouteProviderInterface     → registerRoutes(Router)
├── CommandProviderInterface   → registerCommands(CommandRegistry)
└── NavbarProviderInterface    → registerNavbar()

StreamMiddlewareProviderInterface является ** необязательным ** — он НЕ является частью ModuleInterface. Реализуйте это только в том случае, если модулю необходимо внедрить себя в потоковый конвейер.

// Optional — not in ModuleInterface
class MyModule implements ModuleInterface, StreamMiddlewareProviderInterface {
    public function getStreamMiddleware(): array {
        return [new MyStreamMiddleware()];
    }
}

Класс модуля

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(): 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() NavbarProviderInterface Регистрация элементов навигационной панели
install(): void ModuleInterface Запуск при установке модуля (миграции, начальный запуск)
uninstall(): void ModuleInterface Запуск при удалении модуля (очистка)

Важно — версия может храниться в двух местах. Модуль объявляет свою версию дважды: поле "version" в поле module.json и возвращаемое значение getVersion() в классе module. Сохраняйте их идентичными и изменяйте оба перед издательский. Во время выполнения манифест version имеет приоритет — установка/обновление и водяной знак installed_version сначала читается как module.json, и только потом возвращается to getVersion() — таким образом, устаревший @@ 1@@ тихо выходит из строя и является распространенный источник ошибок типа "выполнена /не выполнена неправильная миграция". Если модуль отправляет файл миграции, database.sql (основная схема) и самый высокий migrations/<semver>.sql дельта также должна соответствовать этой версии.


PHP пространства имен

Каждый модуль находится в выделенном пространстве имен PHP: XcVm\Module\{Pascal}, где {Pascal} - это преобразование имени каталога модуля в 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());
    }
}

Правила:

  • Имя файла основного класса модуля: <PascalName>Module.php — обязательно (соглашение с загрузчиком модулей)
  • Все остальные имена файлов класса: <PascalName><Purpose>.php
  • Добавьте use ClassName; для каждого базового класса, на который ссылается ссылка (базовый модуль, ServiceContainer, маршрутизатор и т.д.)
  • Никогда не импортируйте классы из других модулей — общайтесь через события или контейнер DI

Оформление контейнеров и сервизов DI

Сервисы регистрируются в boot() через ServiceContainer. Контейнер поддерживает:

  • set(id, factory) — отложенный синглтон с помощью вызываемого или прямого значения
  • factory(id, callable) — новый экземпляр для каждого get()
  • decorate(id, callable, priority) — завершение существующей службы
// 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:

public function get(string $id): mixed;  // throws NotFoundException if missing
public function has(string $id): bool;

NotFoundException реализует NotFoundExceptionInterface extends ContainerExceptionInterface.


События PSR-14

События - это простые PHP классы. Отправляйте их через EventDispatcher:

// In any module
EventDispatcher::dispatch(new MyEvent($payload));

// Subscribe
EventDispatcher::listen(MyEvent::class, function (MyEvent $e): void {
    // handle
}, priority: 10);

Приоритет — более высокое целое число = вызывается первым. По умолчанию 0.

**События, которые можно остановить ** — введите AbstractEvent и вызовите $e->stopPropagation():

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):

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
management.logs order ≥ 170

Включение / выключение модулей

Все обнаруженные модули загружаются по умолчанию. Используйте src/config/modules.php для переопределения состояния:

return [
    'my-module' => ['state' => 'disabled'],  // preferred
    // or legacy boolean (still accepted):
    'my-module' => ['enabled' => false],
];

Доступные значения state (подкрепленные ModuleState enum):

Ценность Значение
enabled Загрузка модуля (по умолчанию)
disabled Модуль обнаружен, но пропущен
installing Переходное состояние, заданное ModuleManager во время установки
failed Ошибка установки; модуль пропущен (не загружен)

Панельная диагностика. На странице "Модули" отображается желтый значок "Проблема с зависимостями" рядом со статусом модуля, когда требуемая зависимость отсутствует или не включена (например, plex означает Enabled, а watch - это failed). Во всплывающей подсказке к значку перечислены конкретные проблемы. Это поле dependency_warnings вычисляется с помощью ModuleManager::listModules().

Чтобы переопределить класс, разрешенный для модуля:

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. Определяет порядок загрузки:
    • pruneUnsatisfiableModules() удаляет модули, требуемые зависимости которых недоступны (каскадно, с зарегистрированным предупреждением), поэтому загрузка никогда не прерывается
    • Топологическая сортировка (DFS) по графу зависимостей
    • В пределах одной и той же группы зависимостей выполните сортировку по priority по убыванию, затем по алфавиту
    • Выдает RuntimeException в циклах (циклические зависимости остаются фатальными)
    • Отсутствующие необязательные зависимости автоматически пропускаются
  5. Преобразует имя класса: my-module → ПОЛНОЕ имя XcVm\Module\MyModule\MyModuleModule (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():

$manager->downloadFromPlatform(slug: 'my-module', version: '1.2.0', apiKey: $key);

Под капотом:

  1. @Расширение @0@@ — C загружает, расшифровывает и распаковывает файлы
  2. installModule($slug) — запускает install() в модуле
  3. EventDispatcher::dispatch(new PackageInstalledEvent(...)) — отправляет событие
  4. hotReload($slug, $path) — загружает модуль в текущем запросе без перезапуска PHP-FPM

Изолированные подсистемы

Модуль может быть полностью изолированной подсистемой со своей собственной точкой входа и начальной загрузкой (например, Ministra). Это ** соглашение**, а не маркерный интерфейс — он остается обычный ModuleInterface/BaseModule модуль:

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) с отдельным путем начальной загрузки. Он использует общую инфраструктуру (базу данных, кэш, конфигурацию), но не участвует в основном Router, ModuleLoader::bootAll(), или NavbarRegistry. Реализации boot() и registerRoutes()@ обычно являются оставлено как унаследованное бездействие.


Контроллер

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 и выходим

Задача Cron

Логика Cron (MyCron.php) — только бизнес-логика, без подключения CLI.

Оболочка CronJob (MyCronJob.php) — реализует CommandInterface, использует CronTrait:

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;
    }
}

Регистрация в модуле:

public function registerCommands(CommandRegistry $registry): void {
    $registry->register(new MyCronJob());
}

Объявите запись crontab, переопределив getCronEntries() в классе module:

public function getCronEntries(): array {
    return [
        '*/5 * * * *' => 'cron:my_task',
    ];
}

ModuleLoader::collectCronEntries() объединяет записи всех модулей и StartupCommand / StatusCommand автоматически записывает их в системную crontab — никаких изменений в основных файлах не требуется.

Формат: ключ = выражение cron, значение = имя консольной команды, зарегистрированное через registerCommands().


Версионные миграции (MigratableInterface)

Два механизма, оба аддитивные. Схема на основе файлов**, описанная в разделе Структура каталогов модулей (database.sql мастер + database_drop.sql teardown + migrations/<semver>.sql deltas) используется по умолчанию для простой DDL/seed. MigratableInterface ниже приведен программный путь для обновления. шаги, для которых требуется логика PHP (повторное заполнение данных, условные изменения). Модуль может использовать либо один из них, либо оба; ModuleManager::updateModule() сначала запускает файл delta, затем вызываемые миграции.

Модули, для обновления которых требуется PHP логическая реализация MigratableInterface:

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() очищает ее.

Основные правила:

  • Ключи - это полустрочные строки ('1.1.0', '2.0.0') — version_compare используется упорядочение
  • Каждая миграция выполняется в рамках своей собственной транзакции — сбой откатывает только этот шаг
  • BaseModule предоставляет значение по умолчанию getMigrations(): array { return []; }, поэтому реализация MigratableInterface необязательно

Composer обнаружение пакета

Модули могут распространяться в виде пакетов Composer с "type": "xcvm-module":

{
    "name": "vendor/my-xcvm-module",
    "type": "xcvm-module",
    "extra": {
        "xcvm": {
            "module-path": "src"
        }
    }
}

ModuleLoader автоматически сканирует vendor/composer/installed.json (Composer 1 и 2 форматы) и обнаруживает все установленные пакеты xcvm-module вместе со встроенным src/Modules/ directory. Пакеты дедуплицируются — модуль как в modules/, так и в @@ vendor/ загружается только один раз.


Контрольный список модулей

  • Создать 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));'; никогда не пишите это от руки)
  • Создать <PascalName>Module.php расширение BaseModule
  • Укажите версию в обоих вариантах module.json "version" и getVersion() — они должны совпадать (измените оба перед публикацией)
  • Реализовать boot() для всех сервисов, предоставляемых модулем
  • Реализовать registerRoutes() для конечных точек HTTP/API
  • Внедрить registerNavbar() для элементов панели администратора (или оставить пустыми)
  • (Если кроны) Создать MyCron.php + MyCronJob.php, зарегистрироваться в registerCommands()
  • (Если crons) Переопределяет getCronEntries() в классе module (основной файл не изменяется)
  • (Схема If) Отправка database.sql (мастер), database_drop.sql (демонтаж) и migrations/<semver>.sql дельты
  • (При переносе PHP-логики) Реализовать MigratableInterface::getMigrations()
  • (Если страницы) Создайте контроллер, используя renderUnifiedLayoutHeader/Footer
  • (Если потоковое промежуточное программное обеспечение) Реализуйте StreamMiddlewareProviderInterface отдельно
  • Проверить: php -l src/Modules/<name>/<PascalName>Module.php
  • Проверьте: php console.php --list отображает команды модуля
  • Проверьте: удаление каталога модуля не приводит к фатальной ошибке

часто задаваемые вопросы

Вопрос: Как мне отключить модуль? В src/config/modules.php добавьте 'module-name' => ['state' => 'disabled']. Устаревшая форма 'enabled' => false также принята для обеспечения обратной совместимости.

** Вопрос: Как мне объявить, что мой модуль зависит от другого?** Используйте dependencies в module.json для жесткого удаления (должно присутствовать) или optional_dependencies для мягкого удаления (загружается раньше вашего, если присутствует, и автоматически пропускается, если отсутствует).

** Вопрос: Могу ли я украсить основную услугу?** Да — используйте $container->decorate('service-id', callable, priority) в boot(). Защищенные сервисы (db, settings, config, auth) не могут быть оформлены.

Вопрос: Как мне прослушивать основные события? Вызывайте EventDispatcher::listen(EventClass::class, callable, priority) в любом месте после начальной загрузки, обычно внутри boot() или выделенного класса подписчиков.

Вопрос: Могу ли я отправлять пользовательские события из модуля? Да. Создайте простой класс или расширьте AbstractEvent и вызовите EventDispatcher::dispatch(new MyEvent(...)).

** Вопрос: Для чего нужен StreamMiddlewareProviderInterface?** Это позволяет модулю вводить StreamMiddlewareInterface в конвейер потоковой обработки не изменяя StreamProcess.php. При необходимости применяйте его вместе с ModuleInterface.

Связанные файлы

Файл Роль
src/Core/Module/ModuleLoader.php Обнаруживает, сортирует и загружает модули; PSR-4 распознаватель классов
src/config/modules.php Конфигурация включения модуля / переопределения класса
src/Modules/ Каталоги модулей
src/Core/Module/Contract/ Подинтерфейсы модуля