Хотел было взяться уже за разработку бизнес-логики, но нет. Как только задумался о файлах, как и где их хранить, что инсталлировать, а что нет, сразу стало понятно, что сложность инсталлятора сильно возрастет и будет слишком много кода в одном классе. Поэтому решил вместе с разработкой файлового инсталлятора заодно сделать небольшой рефакторинг процесса инсталляции.
Странно, что эту задачу до сих пор не решил вендор. Видимо, не очень-то ему это надо.
Ранее я выделил 3 этапа инсталляции модуля - БД, файлы и почтовые события. Точнее, выделил их не я, а сам вендор. И именно на этих трех этапах стараются сосредотачиваться и другие разработчики. В частности, в библиотеке notamedia/console.jedi, реализован механизм автоматической установки из cli, где есть явная зависимость на наличие всех этих трех методов.
Не знаю как у других, но у меня инсталлирование файлов модуля всегда вызывает головную боль. И все это из-за коряво спроектированного ядра. Для того, чтобы админские странички корректно открывались, они зачем-то обязательно должны быть в /bitrix/admin директории. По каким-то неизвестным причинам файлы компонентов модуля нужно копипастить в процессе инсталляции в /bitrix/components, хотя я уверен, что вендор мог бы реализовать возможность подключения компонентов из файлов модуля. Скрипты, стили, картинки … все это тоже нужно копировать куда-то в /bitrix/js, /bitrix/css и т.д. Короче - об этом нужно думать. И это вызывает проблемы в процессе инсталляции/деинсталляции - нужно следить за файлами, нужно следить за правами. Нужно при деинсталляции следить за тем, чтобы файлики удалились. Очень много всего того, зачем надо следить. А если файлов дофига - то проблем прибавляется на порядок. И что самое обидное - практически никакой поддержки со стороны вендора в этом плане нет, лишь пара функций. Но обо всем по порядку …
Главный класс инсталлятора
В момент инсталляции битрикс вызывает метод DoInstall(). Определим его как входную точку для всего процесса инсталляции и внутри него будем вызывать методы инсталлирования отдельных этапов. Но надо решить - в каком именно порядке, чтобы было удобно в случае возникновения проблемы откатить процесс инсталляции. В конкретно моем случае, инсталлировать почтовые события не нужно вообще, поэтому нужно лишь придумать, в каком порядке: «БД > Файлы» или «Файлы > БД». И в данном случае ответ очевиден. Регистрация модуля - это часть процесса инсталляции БД, без регистрации модуля нет вообще никакого смысла перекидывать файлы из инсталлятора в битрикс. К тому же при инсталляции БД можно использовать транзакции, которые сами нам все откатят в случае проблем. Поэтому сначала ставим БД, а уже потом файлы. Метод инсталляции почтовых событий оставляем пустым, но не удаляем его для совместимости. Ну и нужно добавить проверки на то, был ли инсталлирован предыдущий шаг, и если нет, то откатываем предыдущие успешно выполненные шаги. Получается нечто такое:
public function DoInstall()
{
if (!$this->InstallDB()) {
return false;
}
if (!$this->InstallFiles()) {
$this->UninstallDB();
return false;
}
if (!$this->InstallEvents()) {
$this->UninstallDB();
$this->UninstallFiles();
return false;
}
return true;
}
Абсолютно то же самое делается в деинсталляторе, только используются методы деинсталляции.
Инсталляция БД
Весь процесс инсталляции у нас уже был сделан ранее. Я просто беру и выношу всю логику инсталляции БД в отдельный класс. Для работы процесса инсталляции мне понадобится, как и ранее, подключение к БД и идентификатор модуля. Соответственно конструктор инсталлятора БД принимает только эти параметры и работает только с ними. Класс инсталлятора БД будет иметь только 2 публичных метода - install() и uninstall() для соответствующих операций. Код, который будет работать внутри инсталлятора, может породить исключение, поэтому нужно ловить это исключение и преобразовывать его в ошибку, которую надо показать пользователю, выполняющему инсталляцию. Получается нечто такое:
public function InstallDB()
{
$connection = Application::getConnection();
$installer = new DatabaseInstaller($this->MODULE_ID, $connection);
try {
return $installer->install();
} catch (\Exception $e) {
global $APPLICATION;
$APPLICATION->ResetException();
$APPLICATION->ThrowException(Loc::getMessage('MAXIMASTER.COUPANDA:DB_INSTALLATION_ERROR', [
'ERROR' => $e->getMessage()
]));
return false;
}
}
Абсолютно то же самое для деинсталлятора, только другие методы вызываются.
А метод install класса инсталлятора БД содержит уже примерно то, что было ранее. Единственное, мы внутри него ловим исключение и выбрасываем снова, чтобы оставить логику отката транзакции внутри класса БД:
public function install()
{
try {
$this->connection->startTransaction();
ModuleManager::registerModule($this->moduleId);
Loader::includeModule($this->moduleId);
$this->createProcessTable();
$this->addPIDColumn();
$this->connection->commitTransaction();
} catch (\Exception $e) {
$this->connection->rollbackTransaction();
throw new \Exception($e->getMessage(), $e->getCode());
}
return true;
}
И опять то же самое при деинсталляции 🙂
В общем-то, на мой взгляд, все очень просто. Остается пара вопросов - куда файл с инсталлятором БД положить, и как его подключать 🙂 С одной стороны - это классы, необходимые для работы модуля, а значит нужно положить их либо в %module_root%/lib, либо в%module_root%/classes. Но, во-первых, их тогда можно будет случайно использовать уже при установленном модуле с помощью автозагрузчика, и тем самым удалить модуль. Вряд ли это, конечно, произойдет, но все же. Во-вторых, это все-таки классы, необходимые не для работы модуля, а только для его инсталляции, а значит ни%module_root%/classes, ни%module_root%/lib не подойдут. Ну ок, раз это относится к инсталляции, то и положим в директорию%module_root%/install, а заодно и проверим, как на это отреагирует команда модерации со стороны битрикса 🙂 Никакого автозагрузчика тут у нас нет, да и не надо, поэтому подключаем данный класс вручную в конструкторе главного инсталлятора.
public function __construct()
{
Loc::loadMessages(__FILE__);
$this->initModuleId();
$this->initModuleVersionDefinition();
$this->initModuleName();
$this->initModuleDescription();
$this->initModulePartnerInfo();
$this->initModuleGroupRights();
require_once __DIR__ . '/DatabaseInstaller.php';
}
Инсталляция файлов
Делаем по аналогии с БД. Создаем класс FileInstaller с тем же самым интерфейсом. Для работы этого инсталлятора необходим путь до директории модуля, а также докрут веб-сервера. Прокинем все необходимые зависимости в этот класс снаружи, чтобы не обращаться к ним через суперглобалки и синглтоны битрикса.
Абсолютно точно известно, что мне нужно будет инсталлировать вызывающие скрипты для административных скриптов модуля (сколько бы их ни было в будущем). Также абсолютно точно известно, что понадобится инсталлировать все компоненты. Об остальном пока думать не будем.
В конструкторе инициализируем пути ко всем директориям, с которыми надо будет работать. Это директории с вызывающими скриптами, компонентами, как со стороны модуля, так и со стороны битрикса. Также пробуем зарезолвить пути до директорий, которые пришли к нам извне, чтобы убедиться в их существовании и строить полные пути к файлам внутри инсталлятора.
Тут уже начинается головняк. Надо думать о разделителе файлов. В большинстве случаев php корректно обработает unix-style файловые пути, но в некоторых случаях при работе на винде это может стать проблемой. Поэтому создадим функцию, которая будет на основании переданных аргументов строить пути к директориям независимо от ОС. Эдакое мелкое подобие path.join() из node.js
public function __construct($moduleDir, $documentRoot)
{
Loc::loadMessages(__FILE__);
$this->documentRoot = realpath($documentRoot);
if (!$this->documentRoot) {
throw new \InvalidArgumentException(Loc::getMessage('MAXIMASTER.COUPANDA:FILE_INSTALLER_DOCROOT_INVALID'));
}
$moduleDir = realpath($moduleDir);
if (!$moduleDir) {
throw new \InvalidArgumentException('MAXIMASTER.COUPANDA:FILE_INSTALLER_MODULE_DIR_INVALID');
}
$this->moduleDir = $moduleDir;
$this->moduleId = basename($moduleDir);
$this->bitrixAdminLinksDir = $this->documentRoot . $this->createPath('bitrix', 'admin');
$this->moduleComponentsDir = $this->moduleDir . $this->createPath('install', 'components');
$this->moduleAdminScriptsDir = $this->moduleDir . $this->createPath('admin');
$this->bitrixComponentsDir = $this->documentRoot . BX_ROOT . $this->createPath('components');
}
protected function createPath()
{
$s = DIRECTORY_SEPARATOR;
$parts = func_get_args();
return str_replace([$s.$s, '//',], $s, $s . implode($s, $parts));
}
Перед началом инсталляции нам надо проверить вообще возможность инсталляции. Проверить это можно лишь одним способом - проверить доступ к директориям на запись. После этого можно уже запускать процессы инсталляции. Вот так будет выглядеть модуль инсталляции и проверки возможности инсталляции:
protected function checkWriteability()
{
if (!is_writable($this->bitrixAdminLinksDir)){
throw new IoException('MAXIMASTER.COUPANDA:FILE_INSTALLER_NOT_ENOUGH_RIGHTS', [
'DIRECTORY' => $this->bitrixAdminLinksDir
]);
}
if (!is_writable($this->bitrixComponentsDir)) {
throw new IoException('MAXIMASTER.COUPANDA:FILE_INSTALLER_NOT_ENOUGH_RIGHTS', [
'DIRECTORY' => $this->bitrixComponentsDir
]);
}
}
public function install()
{
$this->checkWriteability();
$this->installAdminScripts();
$this->installComponents();
return true;
}
Инсталляция админских скриптов
Заключается она в том, чтобы перекинуть файлы из %module_root%/install/admin в /bitrix/admin. Но я уже писал ранее, что хранить пустые файлы с одним require бессмысленно, это мертвый груз. Гораздо логичнее было бы считывать список административных скриптов из %module_root%/admin и создавать на эти файлы ссылки в /bitrix/admin. Тут надо предусмотреть пару ограничений - нужно создавать ссылки только на php файлы, а также не нужно создавать ссылку на файл menu.php Сказано - сделано:
protected function installAdminScripts()
{
// Относительный путь от сайта до директории с файлами
$relAdminScriptDir = str_replace($this->documentRoot, '', $this->moduleAdminScriptsDir);
try {
$iterator = new \DirectoryIterator($this->moduleAdminScriptsDir);
} catch (\UnexpectedValueException $e) {
throw new FileNotOpenedException($this->moduleAdminScriptsDir, $e);
}
foreach ($iterator as $dirObject) {
if (!$dirObject->isFile()) {
continue;
}
$file = $dirObject->getFileInfo();
if ($file->getExtension() !== 'php') {
continue;
}
$baseName = $file->getBasename();
// Пропускаем файл меню, на него не нужно создавать скрипт
if ($baseName === 'menu.php') {
continue;
}
// Как назовем файл
$linkFileName = $this->moduleId . '_' . $file->getFilename();
// Куда его положим
$linkFileDir = $this->documentRoot . $this->createPath('bitrix', 'admin');
// Ссылка на файл в модуле
$link = $this->createPath($relAdminScriptDir, $file->getFilename());
// Содержимое файла-ссылки
$linkFileContents = '<?require $_SERVER[\'DOCUMENT_ROOT\']."' . $link . '";?>';
if (!file_put_contents($this->createPath($linkFileDir, $linkFileName), $linkFileContents)) {
throw new IoException(Loc::getMessage('MAXIMASTER.COUPANDA:FILE_INSTALLER_CANT_CREATE_FILE', [
'FILE' => $linkFileName
]));
}
}
}
Метод деинсталляции, по идее, должен быть таким же длинным. Нужно сканировать %module_root%/admin и по очереди удалять все файлы, которые есть в /bitrix/admin. Но я решил упростить его, сделав просто glob по именам файлов, которые я сам сгенерировал при инсталляции:
protected function uninstallAdminScripts()
{
$pattern = $this->createPath($this->bitrixAdminLinksDir, $this->moduleId.'_*');
foreach (glob($pattern) as $file) {
if (!unlink($file)) {
throw new IoException(Loc::getMessage('MAXIMASTER.COUPANDA:FILE_INSTALLER_CANT_DELETE_FILE', [
'FILE' => $file
]));
}
}
}
Инсталляция компонентов
Для копирования файлов директорий с учетом вложенности в битриксе есть специальная функция - \CopyDirFiles. Ей и воспользуемся. Получается очень простой метод инсталляции компонентов:
protected function installComponents()
{
if (!CopyDirFiles($this->moduleComponentsDir, $this->bitrixComponentsDir, true, true)) {
throw new IoException(Loc::getMessage('MAXIMASTER.COUPANDA:FILE_INSTALLER_CANT_INSTALL_COMPONENTS', [
'DIRECTORY' => $this->bitrixComponentsDir
]));
}
return true;
}
Для удаления файлов в битриксе есть аж две функции - \DeleteDirFiles и \DeleteDirFilesEx. Первая удаляет одноименные файлы из одной директории, которые были найдены в другой директории. Но функция не работает рекурсивно. А вторая работает рекурсивно, но просто удаляет все файлы из директории без сравнения. Поэтому сравнение придется написать:
protected function uninstallComponents()
{
try {
$iterator = new \DirectoryIterator($this->moduleComponentsDir);
} catch (\UnexpectedValueException $e) {
throw new FileNotOpenedException($this->moduleComponentsDir, $e);
}
foreach ($iterator as $dirObject) {
if (!$dirObject->isDir() || $dirObject->isDot()) {
continue;
}
$componentNamespace = $dirObject->getFilename();
foreach (new \DirectoryIterator($dirObject->getRealPath()) as $component) {
if (!$component->isDir() || $component->isDot()) {
continue;
}
$relativeComponentPath = $this->createPath('bitrix', 'components', $componentNamespace, $component->getFilename());
if (!DeleteDirFilesEx($relativeComponentPath)) {
throw new IoException(Loc::getMessage('MAXIMASTER.COUPANDA:FILE_INSTALLER_CANT_DELETE_COMPONENT', [
'COMPONENT' => $relativeComponentPath
]));
}
}
}
}
Ну и осталось подключить этот класс в классе главного инсталлятора, после чего в нужном месте вызвать методы инсталляции и деинсталляции. Вот в принципе и все, инсталлятор файлов готов. Если нужно будет добавить инсталляцию js, css, или картинок, думаю это будет совсем несложно при таком подходе.
Итоги
Бизнес логику в этот раз начать не удалось, зато привели в порядок сам инсталлятор. Надеюсь битриксоиды одобрят этот подход, иначе придется весь код возвращать в класс \maximaster_coupanda, и тогда он разрастется до 400+ строк, что будет не очень удобно в поддержке.
Надо будет также подумать над тем, чтобы вынести этот функционал в другой модуль и реиспользовать его в своих модулях. Но это уже совсем другая история.
Да, кстати! Я тут залил исходники модуля на гитхаб. Можно посмотреть, как это все выглядит в сборе, и попробовать.
Ну и все, теперь можно приступать к реализации бизнес-логики и ключевого функционала.