Хотел было взяться уже за разработку бизнес-логики, но нет. Как только задумался о файлах, как и где их хранить, что инсталлировать, а что нет, сразу стало понятно, что сложность инсталлятора сильно возрастет и будет слишком много кода в одном классе. Поэтому решил вместе с разработкой файлового инсталлятора заодно сделать небольшой рефакторинг процесса инсталляции.
Странно, что эту задачу до сих пор не решил вендор. Видимо, не очень-то ему это надо.
Ранее я выделил 3 этапа инсталляции модуля — БД, файлы и почтовые события. Точнее, выделил их не я, а сам вендор. И именно на этих трех этапах стараются сосредотачиваться и другие разработчики. В частности, в библиотеке notamedia/console.jedi, реализован механизм автоматической установки из cli, где есть явная зависимость на наличие всех этих трех методов.
Не знаю как у других, но у меня инсталлирование файлов модуля всегда вызывает головную боль. И все это из-за коряво спроектированного ядра. Для того, чтобы админские странички корректно открывались, они зачем-то обязательно должны быть в /bitrix/admin директории. По каким-то неизвестным причинам файлы компонентов модуля нужно копипастить в процессе инсталляции в /bitrix/components, хотя я уверен, что вендор мог бы реализовать возможность подключения компонентов из файлов модуля. Скрипты, стили, картинки … все это тоже нужно копировать куда-то в /bitrix/js, /bitrix/css и т.д. Короче — об этом нужно думать. И это вызывает проблемы в процессе инсталляции/деинсталляции — нужно следить за файлами, нужно следить за правами. Нужно при деинсталляции следить за тем, чтобы файлики удалились. Очень много всего того, зачем надо следить. А если файлов дофига — то проблем прибавляется на порядок. И что самое обидное — практически никакой поддержки со стороны вендора в этом плане нет, лишь пара функций. Но обо всем по порядку …
Главный класс инсталлятора
В момент инсталляции битрикс вызывает метод DoInstall(). Определим его как входную точку для всего процесса инсталляции и внутри него будем вызывать методы инсталлирования отдельных этапов. Но надо решить — в каком именно порядке, чтобы было удобно в случае возникновения проблемы откатить процесс инсталляции. В конкретно моем случае, инсталлировать почтовые события не нужно вообще, поэтому нужно лишь придумать, в каком порядке: «БД > Файлы» или «Файлы > БД». И в данном случае ответ очевиден. Регистрация модуля — это часть процесса инсталляции БД, без регистрации модуля нет вообще никакого смысла перекидывать файлы из инсталлятора в битрикс. К тому же при инсталляции БД можно использовать транзакции, которые сами нам все откатят в случае проблем. Поэтому сначала ставим БД, а уже потом файлы. Метод инсталляции почтовых событий оставляем пустым, но не удаляем его для совместимости. Ну и нужно добавить проверки на то, был ли инсталлирован предыдущий шаг, и если нет, то откатываем предыдущие успешно выполненные шаги. Получается нечто такое:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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() для соответствующих операций. Код, который будет работать внутри инсталлятора, может породить исключение, поэтому нужно ловить это исключение и преобразовывать его в ошибку, которую надо показать пользователю, выполняющему инсталляцию. Получается нечто такое:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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 класса инсталлятора БД содержит уже примерно то, что было ранее. Единственное, мы внутри него ловим исключение и выбрасываем снова, чтобы оставить логику отката транзакции внутри класса БД:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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, а заодно и проверим, как на это отреагирует команда модерации со стороны битрикса 🙂 Никакого автозагрузчика тут у нас нет, да и не надо, поэтому подключаем данный класс вручную в конструкторе главного инсталлятора.
1 2 3 4 5 6 7 8 9 10 11 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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)); } |
Перед началом инсталляции нам надо проверить вообще возможность инсталляции. Проверить это можно лишь одним способом — проверить доступ к директориям на запись. После этого можно уже запускать процессы инсталляции. Вот так будет выглядеть модуль инсталляции и проверки возможности инсталляции:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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 Сказано — сделано:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
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 по именам файлов, которые я сам сгенерировал при инсталляции:
1 2 3 4 5 6 7 8 9 10 11 |
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. Ей и воспользуемся. Получается очень простой метод инсталляции компонентов:
1 2 3 4 5 6 7 8 9 10 |
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. Первая удаляет одноименные файлы из одной директории, которые были найдены в другой директории. Но функция не работает рекурсивно. А вторая работает рекурсивно, но просто удаляет все файлы из директории без сравнения. Поэтому сравнение придется написать:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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+ строк, что будет не очень удобно в поддержке.
Надо будет также подумать над тем, чтобы вынести этот функционал в другой модуль и реиспользовать его в своих модулях. Но это уже совсем другая история.
Да, кстати! Я тут залил исходники модуля на гитхаб. Можно посмотреть, как это все выглядит в сборе, и попробовать.
Ну и все, теперь можно приступать к реализации бизнес-логики и ключевого функционала.