Хотел было взяться уже за разработку бизнес-логики, но нет. Как только задумался о файлах, как и где их хранить, что инсталлировать, а что нет, сразу стало понятно, что сложность инсталлятора сильно возрастет и будет слишком много кода в одном классе. Поэтому решил вместе с разработкой файлового инсталлятора заодно сделать небольшой рефакторинг процесса инсталляции.

Странно, что эту задачу до сих пор не решил вендор. Видимо, не очень-то ему это надо.

Ранее я выделил 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+ строк, что будет не очень удобно в поддержке.

Надо будет также подумать над тем, чтобы вынести этот функционал в другой модуль и реиспользовать его в своих модулях. Но это уже совсем другая история.

Да, кстати! Я тут залил исходники модуля на гитхаб. Можно посмотреть, как это все выглядит в сборе, и попробовать.

Ну и все, теперь можно приступать к реализации бизнес-логики и ключевого функционала.

К следующей статье цикла.