Введение

Иногда бывает необходимо повлиять на ход выполнения какого-нибудь процесса, а поскольку изменять ядро продукта запрещено в системе реализован механизм событий. В ходе выполнения некоторых API функций или частях системы, в определенных точках установлены вызовы определённых функций, так называемых обработчиков события.

Событие - это сообщение программного обеспечения либо его части, которое указывает, что произошло. Обработчик события - код который исполняется для обработки события

Само событие описывается тремя ключевыми составляющими:

  • Модуль, являющийся источником события
  • Название события
  • Параметры события (аргументы события)

Например, событие OnPageStart модуля main не имеет параметров события, а просто оповещает что происходит начало выполнения пользовательского кода, а событие OnBeforeCrmDealAdd модуля crm передает параметр содержащий поля добавляемой сделки.

Не следует путать “Событие” как явление и “Событие” как объект описывающее это явление. Помимо глобального вещания в событие (как объект) можно передать конкретный фильтр модулей, обработчикам которых вы хотите его передать.

Как это работает?

В разрезе продукта существует глобальный singleton \Bitrix\Main\EventManager, который хранит все зарегистрированные обработчики событий. Некоторый код инициирует отправку события, EventManager выполняет поиск всех обработчиков удовлетворяющих условиям поиска и последовательно(!) в порядке приоритетов и добавления обработчиков передает его в функцию-обработчик события.

Что может выступать обработчиком события?

  • Callback (php callable тип для которого будет выполнено call_user_func_array)
  • php файл (путь к файлу: TO_PATH - относительный, FULL_PATH абсолютный)

Как подписаться на события?

Существует 2 способа подписки на события: регистрация и добавление. Регистрация обработчика событий осуществляется обычно при модульной разработке, в таком случае все события добавляются в базу данных (таблица b_module_to_module) - это необходимо сделать один раз (при добавлении модуля). Добавления обработчика событий - осуществляется в ходе выполнения программы и существует только в рамках исполняемого скрипта (закончили исполнение - забыли про событие).

Помимо способов подписки на события существуют типы событий: старого ядра и нового ядра. Обработчики событий старого ядра - принимают сколько угодно аргументов, передаваемых по значению или по ссылке и могут ожидать чего-угодно в результате своей работы. Например, уже упомянутое событие OnBeforeCrmDealAdd модуля crm принимает массив полей добавляемого элемента по ссылке, а это значит что его можно изменить. Другой пример событие OnAfterCrmDealAdd, которое не обращает и не обрабатывает возвращаемый результат.

Обработчики событий нового ядра - принимают ровно один аргумент - наследник класса \Bitrix\Main\Event и ожидают возврата \Bitrix\Main\EventResult объекта (допустим null/void).

Для подписки на события необходимо получить инстанс класса EventManager:

$eventManager = \Bitrix\Main\EventManager::getInstance();

И один из перечисленных методов подписки:

  1. Сигнатура метода добавления обработчика событий нового ядра:
$eventManager->addEventHandler(
	$fromModuleId,
	$eventType,
	$callback,
	$includeFile = false,
	$sort = 100
): int

Пояснения аргументов:

  • $fromModuleId - идентификатор модуля-источника события
  • $eventType - событие на которое мы подписываемся
  • $callback - callable обработчик событий (или null, если обработка файлом)
  • $includeFile - false|string путь к файлу - обработчика (если указан $callback - false)
  • $sort - порядок вызова (меньше - раньше)

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

  1. Сигнатура метода добавления обработчика событий старого ядра отличается от нового ядра только названием метода: addEventHandlerCompatible

  2. Сигнатура метода регистрации обработчика событий нового ядра:

$registerEventHandler->registerEventHandler(
	$fromModuleId,
	$eventType,
	$toModuleId,
	$toClass = '',
	$toMethod = '',
	$sort = 100,
	$toPath = '',
	$toMethodArg = []
);

Пояснения аргументов:

  • $fromModuleId - идентификатор модуля-источника события
  • $eventType - событие на которое мы подписываемся
  • $toModuleId - идентификатор модуля, который подписывается
  • $toClass - класс выполняющий обработку (для callback-обработчика, если файловый - пустая строка)
  • $toMethod - метод класса выполняющий обработку (для callback-обработчика, если файловый - пустая строка)
  • $sort - порядок вызова (меньше - раньше)
  • $toPath - относительный (относительно DOCUMENT_ROOT путь к файлу-обработку, для callback - пустая строка)
  • $toMethodArg - дополнительные аргументы передаваемые callback обработчик или доступные в $args переменной файлового обработчика
  1. Сигнатура метода регистрации обработчика событий старого ядра отличается от нового ядра только названием метода: registerEventHandlerCompatible

Например, если мы не разрабатываем модуль и хотим подписаться на событие OnAfterCrmDealAdd модуля crm, то у нас получиться следующий код:

$eventManager = \Bitrix\Main\EventManager::getInstance();

$eventManager->addEventHandlerCompatible(
    'crm',
    'OnAfterCrmDealAdd',
    function( &$arFields )
    {
        // ...
    }
);

Однако в соответствии с оговоренной ранее структурой папки local размещать код обработчика вместе с кодом подписки не следует во избежании раздувания файла с подписками. Наиболее практичный вариант в данном случае: разделение кода-обработчика и кода подписчика.

Например, для нашей структуры папки local разделим указанный выше фрагмент.

В файле local/php_interface/events.php:

$eventManager->addEventHandlerCompatible(
    'crm',
    'OnAfterCrmDealAdd',
    [
    	"\\Vendor\\Subsystem\\Integration\\Crm\\DealCatcher",
    	"handleAfterDealAdd"
    ]
): void;

В файле local/php_interface/classes/Vendor/Subsystem/Integration/Crm/DealCatcher.php:


namespace Vendor/Subsystem/Integration/Crm;

class DealCatcher
{
	/**
	 * Handle crm::OnAfterCrmDealAdd event
	 *     - do something with something
	 *     
	 * @param  array &$fields Some deal fields
	 * @return void
	 */
	public static function handleAfterDealAdd( array &$fields ): void
	{
		// ... Do what you need
	}
}

Обратите внимание на следующие моменты:

  1. Поскольку мы использовали callback-обработчик мы явно обозначили метод handleAfterDealAdd статическим.
  2. Мы добавили php-doc комментарий к методу обработчику: указали какое событие он обрабатывает и что он делает
  3. Комментарий к аргументу $fields - Some deal fields - это поля, которые были указаны при добавлении сделки или были добавлены в процессе работы создания или других обработчиков событий, а не все поля сделки. Событие не гарантирует что вам придет полный набор полей и вам всегда нужно проверять есть ли поле с кодом ... в $fields.
  4. Мы явно указали типы аргументов которые мы ждем.

Как отписаться от события?

Подобно подписке на события существует механизм отписки который обычно используется в двух случаях:

  1. Удаление модуля - когда нужно удалить все зарегистрированные события
  2. Выключение обработчика - когда обработчик события нужен в конкретном месте для конкретного действия и более не требуется.

Для этих ситуаций в продукте существуют следующие методы:

Удаление зарегистрированной подписки на событие:

$registerEventHandler->unRegisterEventHandler(
	$fromModuleId,
	$eventType,
	$toModuleId,
	$toClass = "",
	$toMethod = "",
	$toPath = "",
	$toMethodArg = []
): void;

Пояснения аргументов:

  • $fromModuleId - идентификатор модуля-источника события
  • $eventType - событие на которое мы подписываемся
  • $toModuleId - идентификатор модуля, который подписывается
  • $toClass - класс выполняющий обработку (для callback-обработчика, если файловый - пустая строка)
  • $toMethod - метод класса выполняющий обработку (для callback-обработчика, если файловый - пустая строка)
  • $toPath - относительный (относительно DOCUMENT_ROOT путь к файлу-обработку, для callback - пустая строка)
  • $toMethodArg - дополнительные аргументы передаваемые callback обработчик или доступные в $args переменной файлового обработчика

Удаление подписки на событие:

$registerEventHandler->removeEventHandler(
	$fromModuleId,
	$eventType,
	$iEventHandlerKey
);
  • $fromModuleId - идентификатор модуля-источника события
  • $eventType - событие на которое мы подписываемся
  • iEventHandlerKey - идентификатор обработчика события в списке зарегистрированных обработчиков (то число которое вернул метод addEventHandler или addEventHandlerCompatible)

Создание своих событий

В продукте существует возможность дополнять свой код собственными событиями.

Для этого в своем коде нужно создать объект-события (\Bitrix\Main\Event) и отправить его на обработку (метод send на объекте события), а затем по желанию обработать результат.

Пример подписки на события из документации Битрикса:

$event = new \Bitrix\Main\Event("mymodule", "OnMacrosProductCreate",array($basketId));

$event->send();

if ($event->getResults())
{
	foreach($event->getResults() as $evenResult)
	{
		if ( $evenResult->getResultType() == \Bitrix\Main\EventResult::SUCCESS )
		{
			$arMacros["PRODUCTS"] = $evenResult->getParameters();
		}
	}
}

Несколько советов по созданию свои собственных событий:

  1. К названию события подойдите максимально осознанно. Не следует создавать по событию на каждый чих - старайтесь группировать их по смыслу.
  2. Обрабатывайте все результаты обработчиков. В приведенном примере, если несколько обработчиков будет установлено одному событию, то в массиве $arMacros["PRODUCTS"] будет результата последнего успешно завершенного обработчика.
  3. В параметрах события старайтесь передавать объекты. Все объекты в php передаются по ссылке, а значит каждый обработчик будет иметь доступ к последнему актуальному состоянию. Передача скалярных значений и массивов осуществляется по значению, т.е. изменить их в обработчиках будет нельзя.

Отладка собственных событий

Иногда возникает необходимость в отладке собственных событий, но не имеет смысла собирать информацию со всей системы. Именно для таких ситуаций в каждом объекте события присутствует 2 дополнительных свойства:

  • debugMode - обозначающий, что событие работает в режиме отладки
  • debugInfo - массив произвольной информации, которой можно получить через метод getDebugInfo

Что нужно, чтобы добавить отладку в свой код?

  1. Перед отправкой события необходимо включить отладку:
$event->turnDebugOn();
$event->send();
  1. После отправки события в любом месте (пока существует $event) необходимо вызвать получение отладочной информации:
/**
 * @var array
 */
$debugInfo = $event->getDebugInfo();

$debugInfo - будет содержать массив любой информации которую туда добавят обработчики.

  1. В нужных местах обработчиков добавить запись полезной информации:
$event->addDebugInfo( $ar );

В данном случае на $ar не накладывается никаких ограничений. Это может быть объект, скаляр, массив или любая другая информация которая будет добавлена в ходе выполнения обработчиков событий.

Важное примечание: добавлять туда параметры самого события, обработчик или результат обработки НЕ нужно - эти записи уже будут внесены EventManager-классом. Вы можете дополнить их своими техническими параметрами в ходе своего обработчика для ясности.

Ленивые параметры

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

Частные ситуации

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

Зациклирование обработчиков

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

Псевдокод:

$eventManager->addEventHandlerCompatible(
	'crm',
	'OnBeforeCrmDealUpdate',
	function ( &$arFields )
	{
		// ...
		// 
		
		$dealObject = new \CCrmDeal(false);
		$dealObject->update( $id, $fields );

		// 
		// ...
	}
)

Возможные решения:

  1. Изменение бизнес-логики.
  2. Установка lock-флагов.

Изменение бизнес-логики

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

  1. Не выполнять update. Поскольку в функции-обработчике onBeforeCrmDealUpdate параметр $arFields приходит по ссылке, то мы можем дополнить его своими значениями и эта информация будет сохранена в сделку.
  2. Использовать идемпотентный код. Т.е. перед тем как сделать изменения еще раз проверим что все требования выполнены, а действия раньше не было. Например: если бы нам необходимо было увеличить сумму сделки на 100 рублей если включен флаг “Доставка”, то при изменении сделки мы бы могли проверять что стоимость товаров плюс 100 рублей не равна текущей стоимости сделки.

Установка lock-флагов

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

namespace Vendor/Subsystem/Integration/Crm;

class DealCatcher
{
	static $catcherLock = false;

	/**
	 * Handle crm::OnBeforeDealUpdate event
	 *     
	 * @param  array &$fields Some deal fields
	 * @return void
	 */
	public static function handleBeforeDealUpdate( array &$fields )
	{
		if ( static::$catcherLock === true )
		{
			return;
		}

		static::$catcherLock = true;

		// ... Do what you need
		
		static::$catcherLock = false;
	}
}

Нет проверки наличия аргументов

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

Мы пишем некоторый код:

namespace Vendor/Subsystem/Integration/Crm;

class DealCatcher
{
	/**
	 * Handle crm::OnAfterDealUpdate event
	 *     
	 * @param  array &$fields Some deal fields
	 * @return void
	 */
	public static function handleAfterDealUpdate( array &$fields )
	{
		if ( array_key_exists('ASSIGNED_BY_ID', $fields) )
		{
			// .. do something
			// .. send mail
			// .. do something
		}
	}
}

Кажется что в этом коде нет проблем - мы проверяем есть ли в наборе полей $fields на событии после изменения поле ASSIGNED_BY_ID и выполняем действия. Однако на самом деле - мы не знаем был ли изменен автор. Мы знаем что он есть в наборе полей $fields, но мы же туда могли отправить и того же самого автора и тогда код будет выполнен.

Для исправления этой ситуации мы должны исправить кол:

  1. Ставим дополнительную подписку на onBefore-событие. Если приходит ASSIGNED_BY_ID поле запрашиваем оригинальное значение. Если значение оригинального поля не совпадает со значением текущего поля - устанавливаем lock-флаг.
  2. На onAfter-событии проверяем установленный флаг, а не наличие поля

Полезные ссылки

  1. Зацикливание обработчиков событий
  2. Использование событий