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

Сборка.

К сожалению, 1С-Битрикс не самая удобная CMS в плане разработки модулей для неё (в чем можно было убедиться на протяжении предыдущих десяти статей 🙂). И совершенно непонятно, почему разработчики ядра ведут именно такую политику, усложняя жизнь не только другим разработчикам, но и себе. О каких проблемах вообще идет речь?

  1. Чтобы загрузить модуль в маркетплейс, нужно предоставить его в кодировке windows-1251. Это какой-то жуткий привет из 2000х, даже комментировать нечего.
  2. Страшный инсталлятор. Уже столько раз к нему возвращался на протяжении всего цикла статей, и уверен, что вернусь еще и еще в процессе поддержки и развития модуля. Приходится самостоятельно следить за файлами (а их много даже в таком мелком модуле, как этот), схемой БД. Следить нужно не только при установке, но и при удалении.
  3. Никчемный менеджмент зависимостей, а точнее - почти полное его отсутствие. Все ограничения по совместимости с другими модулями (а также с разными версиями одних и тех же модулей) нужно реализовывать в коде модуля, тогда как у других вендоров все это вынесено за пределы продукта (и лежит на плечах composer, как правило).

Но это все цветочки перед еще одной очень крутой особенностью, как обновления модулей. Механизм обновлений для модулей в маркетплейсе - это просто мракобесие.

Чтобы загрузить новый модуль в маркетплейс, нужно сделать всего пару вещей - закодировать модуль в windows-1251, положить весь модуль в директорию с именем .last_version, и запаковать в архив эту директорию. А вот чтобы загрузить обновление, нужно сделать следующее:

  • создать папочку, которая будет называться также, как выпускаемая версия
  • в эту папочку положить только те файлики, которые обновились
  • создать файлик %module_root%/install/version.php с номером версии и датой выхода версии
  • создать файлики description.ru и description.en с описанием обновления
  • создать файлик version_control.txt, в котором описать зависимости от других модулей
  • создать файлик updater.php, в котором будет содержаться логика обновления (миграции БД, рассовывание файликов по разным частям админки, etc)
  • естественно все файлики должны быть в windows-1251 кодировке 🙂

Ох … Ну что же, будем автоматизировать потихоньку …

Полная сборка модуля

Сначала разберемся с тем, что попроще, а именно - с полной сборкой модуля. Эту задачу можно разбить на несколько этапов:

  • кодирование кода модуля в кодировку windows-1251
  • заливка содержимого в директорию с именем .last_version
  • архивирование

Если составлять алгоритм, то он будет примерно такой:

  • создаем директорию со сборкой
  • копируем в нее все файлы модуля, за исключением тех, которые там не нужны
  • кодируем все php и js файлы в 1251
  • архивируем
  • сохраняем дистрибутив куда-нибудь
  • удаляем директорию со сборкой

Кроме этого я еще хочу автоматизировать создание файла /index/version.php, т.к. следить за версией и в исходниках и в репозитории считаю излишним.

Не знаю кому как, а мне проще этот набор операций реализовать на nodejs с помощью gulp. Можно было бы написать это все с помощью symfony/console, но кода однозначно получится больше, да и процесс этот затянется. А тут можно в несколько строк уложить всю процедуру сборки, т.к. инструментария под gulp написано уже тонна и маленькая тележка. Рассказывать о том, что такое gulp и как его готовить - не вижу смысла (по крайней мере в рамках этой статьи). Информации на этот счет в интернетах предостаточно.

Создаем директорию со сборкой и копируем туда файлы

gulp.task('move', function() {
    return gulp.src(
        [
            './**',
            '!./{node_modules,node_modules/**}',
            '!./*.js',
            '!./*.json'
        ]
    ).pipe(gulp.dest(buildFolder));
});

Тут все просто. Берем все файлы, за исключением node_modules, js и json файлов из корня (также будут исключены файлы с точкой в начале по дефолту), и перекладываем их в директорию со сборкой.

Смена кодировки

gulp.task('encode', function() {
    return gulp.src([
        path.join(buildFolder, '**/*.php'),
        path.join(buildFolder, '**/*.js')
    ])
    .pipe(iconv({encoding: 'win1251'}))
    .pipe(gulp.dest(buildFolder));
});

Тут тоже все просто. Надо лишь подключить модуль gulp-iconv и использовать его для кодирования определенных файлов.

Архивация

gulp.task('archive', function() {
    return gulp.src(path.join(buildFolder, '**/*'))
        .pipe(tar('.last_version.tar'))
        .pipe(gzip())
        .pipe(gulp.dest(buildFolder));
});

И тут тоже все очень просто. Подключаем gulp-tar и gulp-gzip и используем их для сжатия файлов в архив.

Создание файла /install/version.php

Вот тут уже пришлось немного попотеть. За версиями модуля я слежу с помощью тегов git. Значит чтобы указать версию, нужно вытащить название последней версии из репозитория, а также дату создания этого тега. Вся эта информация есть в команде git log, нужно лишь настроить удобоваримый формат для парсинга. После быстрого гуглёжа удалось подобрать команду:

git log --tags --simplify-by-decoration --pretty="format:%cI %d"

Эта команда выводит лог всех тегов с кастомным форматом, где будет указана дата в формате ISO 8601, и указанием тега. Нам нужно вызвать эту команду в процессе сборки, распарсить содержимое, получить дату и номер последней версии и сохранить это дело в php файл. Сказано - сделано:

gulp.task('version', function() {

    git.exec({args: 'log --tags --simplify-by-decoration --pretty="format:%cI %d"'}, function(error, output) {

        const versions = output.trim().split(os.EOL);

        let last = '';
        if (versions.length <= 1) {
            // Если версий нет, то подменим вывод
            last = moment().format() + '  (tag: 0.0.1)';
        } else {
            last = versions[ 0 ];
        }

        const pattern = /(.*)\s\s\(tag: (.*)\)/gi;
        const match = pattern.exec(last);
        const lastVersionDate = moment(match[1]).format('YYYY-MM-DD HH:mm:ss');
        const lastVersion = match[2];

        const fileContents = `<?php
$arModuleVersion = array(
    'VERSION' => '${ lastVersion }',
    'VERSION_DATE' => '${ lastVersionDate }',
);`;
        return file('version.php', fileContents)
            .pipe(gulp.dest(path.join(buildFolder, 'install')));

    });
});

Для выполнения команд git используем gulp-git, для сохранения файлов - gulp-file, а для работы с датой - moment.js. Всего одна регулярка и дело сделано. Есть тут недостатки, конечно … например нет обработки ситуации, когда одному коммиту назначено несколько тегов, но в моем случае это пока не актуально, добавлю по мере необходимости.

Ну и еще пара команд для удаления директории сборки, перемещение готовой сборки в директорию с дистрибутивом, а также команда для запуска сборки, которая запускает все эти команды одним махом. Только тут есть нюанс - gulp по-дефолту будет запускать все задачи параллельно, что может привести к казусам в нашем случае, поэтому нужно сообщить ему, что мы хотим запускать команды последовательно. Для этого можно воспользоваться утилитой run-sequence (а если у вас gulp 4+, то можно и без неё обойтись):

// Сборка текущей версии модуля
gulp.task('build_last_version', function(callback) {
    sequence('clean', 'move', 'version', 'encode', 'archive', 'dist', 'clean', callback);
});

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

Ну и чтобы запустить это дело, нужно лишь поставить зависимости npm и запустить локальный gulp командой npm run build_last_version, что возможно благодаря секции scripts в файле package.json.

В следующей статье буду делать сборку обновления конкретной версии аналогичным способом. А исходники текущей версии можно найти, как всегда, на гитхабе.

Логирование

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

С логированием в битриксе все довольно печально (почему я не удивлен?). Я нашел 3 встроенных способа для сохранения логов:

  • функция AddMessage2Log. Это монстр, который существует в ядре с незапамятных времен, пишет в один файл логи со всех источников, которые вызовут эту функцию. Повлиять на формат лога никак нельзя. Этот вариант я отмел сразу.
  • Класс \CEventLog. Другой монстр, который тоже существует в ядре очень давно. Это обертка над «Журналом событий», при добавлении записей с помощью этого класса данные будут отправляться в БД. Этот вариант я тоже отмел, т.к. работать с этим логом неудобно совсем - нужен доступ к админке; если записей много, то выборка из таблицы в админке будет тормозить; искать данные по логу с помощью интерфейса битрикса неудобно; этот лог будет очищаться агентом, и он может случайно почистить нужные для анализа данные.
  • В новом ядре d7 есть класс \Bitrix\Main\Diag\FileExceptionHandlerLog. Это класс, который используется битриксом для записи лога всех ошибок, возникающих в runtime. В принципе можно использовать и его, если унаследоваться и создать свою реализацию. Но мне не понравилась его явная нацеленность на обработку исключительных ситуаций и ошибок, тогда как мне нужно логировать не только ошибки, но и отладочную информацию. Да и разработчики ядра могут запросто поменять интерфейс работы с этим классом, что приведет к неработоспособности моего модуля, а мне это совсем не нужно.

Больше вариантов я не нашел. Ни один из существующих в битриксе меня не устраивает. Придется писать своё … Я бы рад подключить monolog, но … это ж надо обучить свой модуль работать с composer в битриксе через веб, а это пока не входит в мои планы.

Всемогущий psr

Но к счастью есть такая штука как PSR-3. На гитхабе полно реализаций этого интерфейса, я решил взять за основу пустую болванку psr/log. Увы, придется копировать код к себе, подключить с помощью composer в данном случае - не вариант 🙁 Но зато, когда (если вдруг!) битрикс будет более тесно интегрироваться с composer (в чем я очень сильно сомневаюсь), то можно будет без проблем подменить его на monolog.

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

На основе интерфейса нужно будет создать 2 класса: NullLogger и FileLogger. Один, как видно из названия, будет служить «пустышкой» для случаев, когда логирование отключено на уровне настроек, а другой будет писать логи в файл. В FileLogger нужно при создании экземпляра логгера передать настройку уровня логирования, а также описать метод, который будет записывать информацию в файл. Пока мудрить тут не нужно, опишем простейший формат и простейший способ записи:

public function log($level, $message, array $context = [])
{
    if ($this->onlyErrors) {
        if (!$this->isErrorLevel($level)) {
            return;
        }
    }

    $date = date('Y-m-d H:i:s');
    if (!empty($context)) {
        $newContext = [];
        foreach ($context as $key => $value) {
            if (!is_scalar($value)) {
                $value = print_r($value, true);
            }

            $newContext[ '{' . $key . '}' ] = $value;
        }
    }

    $message = str_replace(array_keys($newContext), array_values($newContext), $message);
    $message = $date . ' [' . $level . '] ' . $message . PHP_EOL;
    file_put_contents($this->getPath(), $message, FILE_APPEND);
}

Хранить настройку уровня логирования будем в стандартной таблице с опциями модулей битрикса. Нужно под это дело в скрипте %module_root%/options.php описать новую форму и способ сохранения данных формы.

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

Менеджмент ограничений

Это последняя часть, которая осталась не реализованной по техническому заданию. Я оставил её на конец, как и права доступа, т.к. гораздо проще управлять ограничениями тогда, когда весь функционал уже готов.

В общем - создаем класс:

namespace Maximaster\Coupanda\Compability;

use Bitrix\Main\Error;
use Bitrix\Main\Localization\Loc;
use Bitrix\Main\ModuleManager;
use Bitrix\Main\Result;

class CompabilityChecker
{
    public function isCompatible()
    {
        return $this->check()->isSuccess();
    }

    public function check()
    {
        $result = new Result();
        if (!$this->isSaleInstalled()) {
            $result->addError(new Error(Loc::getMessage('MAXIMASTER.COUPANDA:SALE_IS_NOT_INSTALLED')));
        } else {
            if (!$this->isSaleVersionCompatible()) {
                $result->addError(new Error(Loc::getMessage('MAXIMASTER.COUPANDA:SALE_IS_VERY_OLD')));
            }
        }

        return $result;
    }

    public function isSaleInstalled()
    {
        return ModuleManager::isModuleInstalled('sale');
    }

    public function isSaleVersionCompatible()
    {
        $saleVersion = ModuleManager::getVersion('sale');
        if (!$saleVersion) {
            return false;
        }

        return \CheckVersion($saleVersion, '14.11.0');
    }
}

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

Локализация

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

Для того, чтобы локализовать фразы в js файле компонента, пришлось изменить способ подключения js файла, теперь это делается с помощью \CJSCore. Этот класс может регистрировать js-библиотеку, к которой можно присобачить css файлы и файл с локализацией. Для этого в директории с шаблоном компонента переименуем файл script.js в generator.js, а в файле component_epilog.php нашего шаблона опишем новую библиотеку, которая зависит от jquery и модуля popup:

\CJSCore::RegisterExt('maximaster_coupanda_generator', [
    'js' => $templateFolder . '/generator.js',
    'lang' => $templateFolder . '/lang/' . LANGUAGE_ID . '/generator.php',
    'rel' => ['jquery', 'popup'],
]);

\CJSCore::Init(['maximaster_coupanda_generator']);

Если подключать скрипт таким образом, то в js в объекте BX.message появятся все языковые фразы из указанного файла локализации, а также предварительно будут подключены jquery и библиотека обработки всплывашек.

Итоги

Теперь можно сказать, что модуль готов на 100% от задуманного стартового функционала. Нужно еще все хорошенько протестировать, а в идеале написать автотесты (надо было бы вообще с самого начала об этом подумать 🙂 ). Модуль реализован почти по феншуйным законам битрикса, специально старался сделать именно так, как того требует система по максимуму, лишь в нескольких местах пытаясь обойти неудобные моменты (и еще неизвестно, как на них отреагирует команда модерации модулей))).

Из того, что еще нужно сделать - это допилить сборку модуля, чтобы собирать обновления, что и станет темой следующей статьи цикла. А дальше уже можно будет знакомиться с маркетплейсом изнутри.

Ну и как всегда, код модуля доступен на гитхабе.

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