Опыт разработки модуля для 1С-Битрикс

2020.10.08
Обзор опыта работы с 1С-Битрикс начиная от установки CMS, продолжая заглядывать внутрь и заканчивая разработкой модулей

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

Устновка и использование CMS

На странцие загрузки есть несколько вариантов:

Есть пробная версия на 30 дней, после чего в верхней части сайта будет сообщение:

Срок работы пробной версии продукта истек. Через две недели этот сайт полностью прекратит свою работу. Вы можете купить полнофункциональную версию продукта на сайте www.1c-bitrix.ru.

А еще через несколько дней сайт перестанет работать и будет только сообщение:

Срок работы пробной версии продукта истек. Вы можете купить полнофункциональную версию продукта на сайте www.1c-bitrix.ru.

Это контроллируется обфусцированным файлом bitrix/modules/main/include.php.

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

Оформление модуля

Если модуль разрабатывается индивидуально для одного/нескольких проектов, то размещаться в маркетплейсе вовсе необязательно, однако обновлять модуль средствами движка не представляется возможным.

Файлово модуль можно разместить в local/modules/module_id если директория модуля будет называться просто module_id (название модуля), то его можно найти в админке Настройки - Модули, а настройки в Настройки - Настройки модулей. Если именование директории модуля будет по правилам тогда модуль можно найти в админке Marketplace - Установленные решения.

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

Настройки модуля

Здесь вскользь упоминается про настройки. Оказалось не сложно ... до тех пор пока не потребовалась поддержка мультисайтовости. Но это можно подсмотреть в модулях "из коробки", например хороший пример модуль "Бизнес процессы" файл настроек которого располагается по пути bitrix/modules/bizproc/options.php.

Обширным источником знаний об организации настроек в своем модуле для 1С-Битрикс являются исходники модулей из коробки.

Настройки для одного сайта на движке 1С-Битрикс: Настройки для одного сайта на движке 1С-Битрикс

Организация настроек модуля храниться в файле options.php в директории модуля.

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

Это выглядит как-то не консистентно, с одной стороны инфраструктура движка предоставляет богатый функционал для работы с движком и модулем интернет-магазина (есть даже почти PSR4) с возможностью отделить логику от представления, с другой стороны настройки модулей без таких возможностей.

Однако, по ходу реализации настроек модуля можно попытаться отделить логику от представления, но не будем пока отходить от принятой концепции :)

Файл будет содержать следующие разделы кода:

Инициализация

Потребуется 3 объекта ядра движка запрос, настроки, загрузчик:

use Bitrix\Main\HttpApplication;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Loader;

Затем необходимо получить текущий запрос и загрузить модуль если в логике настроек (сохранение/проверка) используется код модуля, как было в моем случае:

$request = HttpApplication::getInstance()->getContext()->getRequest();

// идентификатор модуля это название его директории
// файл настроек находится по пути idModule/options.php
$idModule = dirname(__DIR__);
Loader::includeModule($idModule);

Теперь массив настроек:

$tabs = [
    [
        "DIV"       => "css класс",
        "TAB"       => "Название вкладки настроек модуля",
        "OPTIONS"   => [
            [
                "id_point", // идентификатор пункта настроек
                "Label",    // надпись возле пункта настроек
                "default",  // значение по умолчанию
                
                // опиание GUI элемента, для каждого вида свое описание
                [
                    "text", 
                    11
                ]
            ],
            ...
        ]
    ]
];

Страница настроек модуля состоит из вкладок, в массиве $tabs, каждая вкладка должна располагаться в отдельном массиве.

В ключе OPTIONS располагаются непосредственно настройки вкладки. Рассмотрим несколько видов GUI элементов:

[
    "token", 
    "Токен компании:", 
    "", // по умолчанию пусто
    [
        "text", 
        20 // размер строки
    ]
]
[
    "only2", 
    "Создавать только второй чек:", 
    "N", // по умолчанию не отмечено
    ["checkbox"]
]
/* $paySystems - ассоциативный массив систем оплат где:
 - ключ - идентификатор системы оплаты
 - значение - название
*/
[
    "paysystem", 
    "Создавать чек для оплаты:", 
    array_keys($paySystems)[0],
    [
        "selectbox", 
        $paySystems
    ]
]

Проверка и сохранение настроек

Для начала нужно проверить что пришел POST запрос с актуальным идентификатором сессии:

if ($request->isPost() && check_bitrix_sessid()) {
    // принятие
    // проверка
    // сохранение
}

Теперь внутри мы можем получить отправленные настройки проверить и сохранить. Разделим эти процессы и сначала соберем настройки на основании массива настроек $tabs["OPTIONS"]:

$options = [];
foreach ($tab["OPTIONS"] as $option) {
  if (!is_array($option)) {
    continue;
  }

  if ($request["apply"]) {
    if($type == "checkbox") {
      $options[$option[0]] = $request->getPost($option[0]);
    }
    else {
      $options[$option[0]] = trim($request->getPost($option[0]));
    }
  }
  else if ($request["default"]) {
    $options[$option[0]] = trim($option[2]);
  }
}

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

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

И наконец сохранение:

foreach ($options as $key => $value) {
    Option::set($idModule, $key, $value);
}

// перенаправление юзера на страницу настроек модуля
// иначе при обновлении этой страницы с модулем форма будет отправлена повторно
LocalRedirect($APPLICATION->GetCurPage()."?mid=".$idModule."&lang=".LANG);

Рендер настроек

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

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

$tabControl = new CAdminTabControl("tabControl", $tabs);
$tabControl->Begin();

?><form action="<?php echo($APPLICATION->GetCurPage()); ?>?mid=<?php echo($idModule); ?>&lang=<?php echo(LANG); ?>" method="post">
<?php
$tabControl->BeginNextTab();
    
foreach ($tabs as $tab) {
    if ($tab["OPTIONS"]) {
        $tabControl->BeginNextTab();
        __AdmSettingsDrawList($idModule, $tab["OPTIONS"]);
    }
}
$tabControl->Buttons();
?><input type="submit" name="apply" value="Применить" class="adm-btn-save" />
<?php echo(bitrix_sessid_post()); ?>
</form>
<?php $tabControl->End();

Мультисайтовость

Настройки для нескольких сайтов на одном инстансе движка 1С-Битрикс (мультисайтовость): Настройки для нескольких сайтов на одном инстансе движка 1С-Битрикс (мультисайтовость)

В самом начале был упомянут модуль "Бизнес процессы", файл настроек которого расположен по пути bitrix/modules/bizproc/options.php. Это наиболее простой и наглядный пример организации настроек для модуля с поддержкой мультисайтовости 1С-Битрикс.

Что же меняется в базовой реализации настроек модуля при поддержке мультисайтовости? Немного:

$siteIds = GetSites();

// получить массив сайтов [lid => name, ...]
function GetSites()
{
  $res = Bitrix\Main\SiteTable::getList();
  $sites = [];
  while ($site = $res->fetch()) {
    $sites[$site["LID"]] = $site["NAME"];
  }

  return $sites;
}

Информацию о сайтах на инстансе 1С-Битрикс можно посмотреть в БД в таблицах b_lang и b_landing_site.

Рендер

Основная обертка формы и вкладки настроек такие же. Однако, кроме вкладок настроек модуля, теперь нужны подвкладки внутри вкладок, создать их можно при помощи CAdminViewTabControl, документацию по которой не удалось найти. Создаем подвкладки:

foreach ($siteIds as $siteId => $siteName) {
  $subTabs[] = [
    "DIV" => "opt_site_$siteId", 
    "TAB" => "$siteName ($siteId)", 
    'TITLE' => '', 
    "OPTIONS" => $tabs["OPTIONS"]
  ];
}
$subTabControl = new CAdminViewTabControl("subTabControl", $subTabs);

__AdmSettingsDrawList здесь уже не подходит, так как она рендерит верстку вне подвкладок. Штатного средства 1С-Битрикс найти не удалось, поэтому была написана своя функция рендера настроек RenderSettings.

function RenderSettings($idModule, $settings, $siteId)
{
  $a = [];
  foreach ($settings as $value) {
    $name = $value[0];
    $name2 = $value[0]."_".$siteId;
    $desc = $value[1];
    $default = $value[2];
    $type = $value[3][0];
    $val = $value[3][1];

    //$realval = Option::get($idModule, $name, $default, $sSiteId);
    $realval = settings::get($name, $siteId);
    $realval = ($realval !== null ? $realval : $default);

    $inner = "";

    switch ($type) {
      case "text":
        $inner = '<input type="text" size="'.$val.'" maxlength="255" value="'.$realval.'" name="'.$name2.'">';
      break;
      case "selectbox":
        $options = [];
        foreach($val as $key2 => $val2)
          $options[] = '<option value="'.$key2.'"'.($realval == $key2 ? 'selected=""' : '').'>'.$val2.'</option>';
        $inner = '<select name="'.$name2.'">'.implode("\n", $options).'</select>';
      break;
      case "checkbox":
        $inner = '<input type="checkbox" id="'.$name2.'" name="'.$name2.'" value="'.$realval.'"'.($realval == "Y" ? 'checked=""' : '').'  class="adm-designed-checkbox">
        <label class="adm-designed-checkbox-label" for="'.$name2.'" title=""></label>';
      break;
      default:
      break;
    }

    $a[] = '<div style="display: block; margin-bottom: 5px;"><label style="display: inline-block;width:50%;text-align: right;">'.$desc.'</label>'.$inner.'</div>';
  }

  return implode("\n", $a);
}

Функция может обработать только text, select, checkbox, другое пока не понадобилось. На вход принимает 3 аргумента:

Функция вернет рендер GUI элементов, при этом к названию элементов добавляться постфикс _${LID}, чтобы их можно было как-то отличить между сайтами.

Прежде чем пройтись циклом по вкладкам массива $tabs необходимо стартовать процесс обработки вкладок и затем отрендерить настройки:

$subTabControl->Begin();
foreach ($siteIds as $siteId => $siteName) {
  $subTabControl->BeginNextTab();

  foreach ($tabs as $tab) {
    if ($tab["OPTIONS"]) {
      echo RenderSettings($idModule, $tab["OPTIONS"], $siteId);
    }
  }
}
$subTabControl->End();

Прием настроек при сохранении

Имея мультисайтовость имеем одинаковые имена настроек для каждого сайт, но разные значения. Названия GUI элементов у нас имеют постфикс с _${LID} сайта, значит проходимся по массиву сайтов, используя _${LID} сайта проходимся по массиву настроек и пробуем извлечь настройки:

$options = [];
foreach ($siteIds as $siteId => $name) {
  $options[$siteId] = [];
  foreach ($tabs as $tab) {
    foreach ($tab["OPTIONS"] as $option)
    {
      if (!is_array($option)) {
        continue;
      }

      $type = $option[3][0];

      if($request["apply"]) {
        if($type == "checkbox") {
          $options[$siteId][$option[0]] = $request->getPost($option[0]."_".$siteId) !== null ? "Y": "N";
        }
        else {
          $options[$siteId][$option[0]] = trim($request->getPost($option[0]."_".$siteId));
        }
      }
      else if($oRequest["default"]) {
        $options[$siteId][$option[0]] = trim($option[2]);
      }
    }
  }
}

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

БД

Есть специальное пространство имен, а есть глобальная переменная $DB - объект для работы с базой данных. Документация исчерпывающая, я использовал в основном чистый SQL и ForSql для экранирования.

Так как наша компания предоставляет онлайн-кассы с возможностью вывода из оборота маркированных товаров (передача кодов маркировки в "честный знак"), то для каждой позиции товара необходимо иметь поле ввода кода маркировки, что поддерживается в движке из коробки. Однако, не без приколов ...

Максимальная длина кода в таблице БД 1С-Битрикс составляет 100 символов, этого не хватает для кодов маркировки обуви, которые состоят из 127 символов, и не достаточно для кодов маркировки ЕГАИС 3.0 размером 150 символов.

Полазив по форумам, понял что эта проблема еще не решена, поэтому написал свое решение:

ALTER TABLE `b_sale_store_barcode`
CHANGE `MARKING_CODE` `MARKING_CODE` VARCHAR(150) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL;

Список статусов заказов и отгрузок

Сам список статусов (заказа/отгрузки) можно увидеть в админке 1С-Битрикс в разделе Магазин=>Настройки=>Статусы.

Страница статусов в админке 1с-Битрикс

Эта страница расположена по адресу /bitrix/admin/sale_status.php попробуем открыть этот файл и видим там:

require_once($_SERVER["DOCUMENT_ROOT"]."/bitrix/modules/sale/admin/status.php");

Идем по указанному пути ... и находим внутри файла код формирования данной страницы. Немного посмотрев обнаруживаем интересные строки напоминающие формирование запроса к БД:

$query = \Bitrix\Sale\Internals\StatusTable::query();
$query->setSelect([
    'ID', 'SORT', 'TYPE', 'NOTIFY', 'LID' => 'STATUS_LANG.LID',
    'COLOR' ,'NAME' => 'STATUS_LANG.NAME', 'DESCRIPTION' => 'STATUS_LANG.DESCRIPTION'
]);
$query->where(
    \Bitrix\Main\ORM\Query\Query::filter()
        ->logic('OR')
        ->where('STATUS_LANG.LID', '=', LANGUAGE_ID)
        ->where('STATUS_LANG.LID', NULL)
);

Как оказалось \Bitrix\Sale\Internals это пространство имен с классами для работы с таблицами БД модуля интернет-магазин, а вышеприведенный код использует функционал ORM 1С-Битрикс.

Классы из \Bitrix\Sale\Internals подгружаются в файле /bitrix/modules/sale/autoload.php. наследуются от \Main\Entity\DataManager.

Это сообщение на странице документации, (не рекомендуется, но целый модуль построен на этом): Сообщение на странице

Сначала нужно получить объект запроса:

$query = \Bitrix\Sale\Internals\StatusTable::query();

Теперь нужно указать что будем выбирать из таблицы (таблица выборки уже указана в самом классе):

$query->setSelect([
    'ID', 'SORT', 'TYPE', 'NAME' => 'STATUS_LANG.NAME'
]);

В моей задаче требовалось выбрать все статусы относящиеся к доставке, поэтому добавляем условие TYPE='D':

$query->where(
    \Bitrix\Main\ORM\Query\Query::filter()
        ->logic("AND")
        ->where('TYPE', '=', "D")
);

Хотелось бы поставить фильтр по языку STATUS_LANG.LID=LANGUAGE_ID, чтобы показывать для каждого языка поддерживаемого сайтом определенный статус (и те строки для которых язык не определен STATUS_LANG.LID = NULL):

$query->where(
    \Bitrix\Main\ORM\Query\Query::filter()
        ->logic('OR')
        ->where('STATUS_LANG.LID', '=', LANGUAGE_ID)
        ->where('STATUS_LANG.LID', NULL)
);

Устанавливаем сортировку по полю SORT в прямом порядке:

$query->setOrder(["SORT" => "ASC"]);

На этом этапе можно посмотреть как будет выглядеть наш запрос к БД таким образом $query->getQuery();, получим что-то подобное:

SELECT 
    `sale_internals_status`.`ID` AS `ID`,
    `sale_internals_status`.`SORT` AS `SORT`,
    `sale_internals_status`.`TYPE` AS `TYPE`,
    `sale_internals_status_status_lang`.`NAME` AS `NAME`
FROM `b_sale_status` `sale_internals_status` 
LEFT JOIN `b_sale_status_lang` `sale_internals_status_status_lang` ON `sale_internals_status`.`ID` = `sale_internals_status_status_lang`.`STATUS_ID`
WHERE `sale_internals_status`.`TYPE` = 'D' AND (`sale_internals_status_status_lang`.`LID` = 'ru' OR `sale_internals_status_status_lang`.`LID` IS NULL)
ORDER BY `SORT` ASC

Казалось бы несколько строк кода php, а сгенерирован SQL с JOIN. Все потому что ORM и внутри уже настроены связи между объектами.

Теперь выполняем запрос и распределяем данные запроса по ассоциативному массиву:

$res = $query->exec()->fetchAll();
foreach ($aRes as $value) {
    $a[$value["ID"]] = $value["NAME"];
}

В итоге получим что-то подобное:

Array
(
  [DN] => Ожидает обработки
  [DA] => Комплектация заказа
  [DG] => Ожидаем приход товара
  [DT] => Ожидаем забора транспортной компанией
  [DS] => Передан в службу доставки
  [DF] => Отгружен
)

Крон

В 1С-Битрикс крон назвается агент. Документация исчерпывающая :)

События

В новой версии ядра рекомендуется использовать новый подход с использованием EventManager, а здесь список событий. Описание метода registerEventHandler:

$eventManager->registerEventHandler(
  "sale",                       // id модуля в котором есть это событие
  "OnSalePaymentEntitySaved",   // название события
  $this->MODULE_ID,             // ид модуля который регистрирует обработчик события
  "events",                     // название класса обработчика
  "OnSalePaymentEntitySaved"    // название статического метода обработчика
);

Функция обработчик выглядит примерно так:

public static function OnSalePaymentEntitySaved(Bitrix\Main\Event $event)
{
  // извлечение сущности сообщения
  $payment = $event->getParameter("ENTITY");
  ...
}

Удаление обработчика (например при деинсталяции модуля) производится методом unRegisterEventHandler, значение аргументов аналогично registerEventHandler.

Мне довелось использовать следующие события:

$payment = $event->getParameter("ENTITY");
function OnAdminContextMenuShow(&$aItems)
{
  $items[] = [
        "TEXT" => "Текст на кнопке", 
        "LINK" => "javascript:js код",
        "TITLE" => "Всплывающая подсказка",
        //"MENU" => $array,  //массив вложенных кнопок, такого же формата
        "ICON" => "btn_delete"  //стиль кнопки 
    ];
}

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

return new \Bitrix\Main\EventResult(
    \Bitrix\Main\EventResult::ERROR,
    new \Bitrix\Sale\ResultError("Текст ошибки"),
    'sale'  // видимо id модуля для которого генерируется ошибка :)
);

Подгрузка статики

Чтобы использовать CSS или JavaScript на страницах сайта необходимо зарегистрировать свои расширения.

Изначально я предполагал что статика будет загружаться из каталога модуля, все так и было на реальном хостинге где был nginx. Все работало. Но модераторы тестировали модуль в иной среде - на локальном сервере и только на apache (судя по всему), а в директории модулей есть конфиг .htaccess который запрещает туда обращаться:

Deny from All

Тогда я пошел смотреть как решают эту проблему другие модули ...

Оказывается каждый модуль копирует свою статику в директорию /bitrix/js/module_id при этом не каждый удаляет эти данные при деинсталяции. А я то думал: почему на чистой установке движка >130.000 файлов ...

Всю статику пришлось перенести в директорию модуля install и при установке модуля (STATIC_DIR_NAME - директории для копирования):

CopyDirFiles(
    $_SERVER["DOCUMENT_ROOT"] . "/bitrix/modules/" . $this->MODULE_ID."/install/static/js",
    $_SERVER["DOCUMENT_ROOT"] . "/bitrix/js/" . STATIC_DIR_NAME, true, true
);

При деинсталяции модуля удалять файлы так:

DeleteDirFilesEx("/bitrix/js/" . STATIC_DIR_NAME);

А регистрация расширений выглядит так:

CJSCore::RegisterExt("название_расширения", ['js' => "/bitrix/js/" . STATIC_DIR_NAME . "/script.js"]);
CUtil::InitJSCore(["название_расширения"]);

Ajax

Необходимо зарегистрировать свое расширение и инициализировать его, как в примере выше.

Другой вопрос: где это сделать? В моем случае понадобилось подключать в обработчике события OnAdminContextMenuShow чтобы вывести на админ-панель дополнительные кнопки и прикрутить к ним логику.

В директории модуля необходимо создать директорию lib, а в ней php скрипт/скрипты где разместить пространство имен VendorName\ModuleName. Есть возможность разместить все в глобальном пространстве имен.

Наш пример:

Необходимо реализовать класс, где методы с постфиксом Action будут доступны для вызова из JavaScript (без учета регистра и без постфикса Action):

// если не глобальное пространство имен, тогда надо указать
// namespace namespace;

// подключение пространства имен контроллера
use Bitrix\Main\Engine\Controller;

class ajax extends Controller
{
    public function testAction()
    {
        // объект HttpRequest
        $request = $this->getRequest();
        
        return ["response" => "ok"];
    }
}

При помощи HttpRequest, можно получить данные запроса.

Теперь этот метод можно вызвать на клиентской части через BX.ajax.runAction:

BX.ajax.runAction(
    // вызываемый метод формата: partner:module_name.namespace.class_name.method
    'partner:module_name.name.ajax.test', 
    {
        // данные для пост запроса
        data: {param: 10}
    }
)
.then(function(response) {
    // код после получения данных от сервера, обьект ответа в response
});

Придет объект вида:

{
  status: "success",
  data: {/* то что вернул метод класса (ajax::testAction) */}
  errors: []
}

Popup

Для popup есть документация и вот еще как создать popup почти одной строкой :)

В моем случае при нажатии на кнопку открывался popup, юзер мог закрыть и снова нажать на кнопку (чтобы опять увидеть это окно), но почему то повторно окно не открывалось, пришлось каждый раз создавать popup заного.

Обновления

Это оказалось самым простым, в документации кратко написано как делать обновления. Только description.ru (описание изменений обновления на русском языке) необходимо писать в кодировке Windows-1251.

Итог

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

По ходу разработке, лояльности к подобного рода отечественным решениям стало больше, это та же CMS что и другие, только отечественная :)

В телеграм канале DevOps от первого лица можно оставить комментарий или почитать интересные истории из практики DevOps