Знаете, как бывает, задачу надо сделать не хорошо, а быстро, т.к. на нее завязаны деньги, партнеры и много всего другого очень важного для бизнеса. В итоге где-то что-то не продумали, где-то упустили, что-то захардкодили, в общем, все ради скорости. И, вроде, все хорошо, все работает, но
Через какое-то время оказывается, что функционал нужно расширять, а сделать это сложно, не хватает гибкости. За настройками, конечно, обращаются к разработчикам. И, конечно же, это отвлекает от других задач и не покидает ощущение, что время потрачено зря.
Вот и у меня возникла такая ситуация. Когда-то по-быстрому запилили интеграцию с системой e-mail-маркетинга, а потом посыпались задачи по типу если пользователь сделал это, необходимо вот это записать вот сюда. Из-за отсутствия наглядности бизнес-процессов возникало их пересечение, данные затирали друг друга, записывалось не то.
Хочу рассказать, как вышли из этой ситуации.
В какой-то момент в системе что-то или кто-то генерирует событие. Например, пользователь зарегистрировался, обновил данные профиля, совершил покупку и т.п.
Это событие нужно поймать и обработать. Например, отправить письмо, передать данные в CRM или какую-то другую систему. Обработчиков может быть много и их количество будет увеличиваться со временем.
Необходимо связать событие и обработчики. Запускать их нужно как безусловно, так и по некоему условию. Например, если пользователю 20 лет, то ему отправляем письмо одного вида, а если 60, то другого.
Разработка ведется на PHP на Laravel. В этом фреймворке уже есть события и обработчики, на их основе и построена подсистема.

Обрабатывать все возможные существующие события в системе не целесообразно, будем перехватывать только события, реализующие специальный интерфейс. Согласно ему, каждое событие должно сообщать, какие данные несёт в себе и иметь свой уникальный идентификатор.
<?php App\Interfaces\Events use Illuminate\Contracts\Support\Arrayable; /** * System event * @package App\Interfaces\Events */ interface SystemEvent extends Arrayable { /** * Get event id * * @return string */ public static function getId(): string; /** * Event name * * @return string */ public static function getName(): string; /** * Available params * * @return array */ public static function getAvailableParams(): array; /** * Get param by name * * @param string $name * * @return mixed */ public function getParam(string $name); }
Ещё есть пул доступных событий. В нем регистрируются те события, которые являются системными и имеют какое-то значение для бизнес-процессов.
<?php namespace App\Interfaces\Events; /** * Interface for event pool * @package App\Interfaces\Events */ interface EventsPool { /** * Register event * * @param string $event * * @return mixed */ public function register(string $event): self; /** * Get events list * * @return array */ public function getAvailableEvents(): array; /** * @param string $alias * * @param array $params * * @return mixed */ public function create(string $alias, array $params = []); }
Обработчик событий это просто класс, имеющий определённый интерфейс. И он, как и событие, сообщает, какие данные может принимать, что получается на выходе, имеет название и ID.
<?php namespace App\Interfaces\Actions; /** * Interface for system action * @package App\Interfaces\Actions */ interface Action { /** * Get ID * * @return string */ public static function getId(): string; /** * Get name * * @return string */ public static function getName(): string; /** * Available input params * * @return array */ public static function getAvailableInput(): array; /** * Available output params * * @return array */ public static function getAvailableOutput(): array; /** * Run action * * @param array $params * * @return void */ public function run(array $params): void; }
Обработчики так же регистрируются в реестре с таким же интерфейсом как у пула событий.
Рассмотрим gui настройки связи событие-обработчик. У меня он реализован с использованием knockout.js, но это не принципиально.

Как вы видите, есть блок в котором настраиваются условия запуска обработчика. Первая колонка параметр из события, затем идёт условие и значение, с которым будет сравнение.
В настройке обработчика так же три основных колонки. Первая параметр из обработчика. В него нужно передать параметр из события(это вторая колонка). Параметр события можно не задавать, значение может быть константой. Например, в случае регистрации по e-mail передаётся 0, а в случае регистрации через соц.сеть передаётся 1, или какие-то человекопонятные значения.
В самом начале говорил, что все началось с интеграции с системой email- маркетинга Sendsay. В момент создания сущности в нашей системе, должна создаваться так называемая анкета на стороне Sendsay. При создании, в неё не передаются пользовательские данные, все статично. Это тот случай, когда нужно задать произвольные значения. Добавляем строку, вбиваем название поля в анкете, а в значение тип поля.
Связь настроили, посмотрим на главный обработчик событий.
<?php namespace App\Interfaces\Events; /** * Interface for event processor * @package App\Interfaces\Events */ interface EventProcessor { /** * Process system event * * @param SystemEvent $event * @param array $settings */ public function process(SystemEvent $event, array $settings = []): void; }
<?php namespace App\Interfaces\Events; /** * Interface for event processor * @package App\Interfaces\Events */ interface EventProcessor { /** * Process system event * * @param SystemEvent $event * @param array $settings */ public function process(SystemEvent $event, array $settings = []): void; }
Метод process будем вызывать в SystemEventListener.
<?php namespace App\Listeners; use App\Interfaces\Events\SystemEvent; use App\Interfaces\Events\EventProcessor; use App\Models\EventSettings; use Illuminate\Support\Collection; class SystemEventListener { /** @var EventProcessor */ private $eventProcessor; public function __construct(EventProcessor $eventProcessor) { $this->setEventProcessor($eventProcessor); } public function handle(SystemEvent $event): void { EventSettings::query()->where('is_active', true)->where('event_id', $event::getId())->chunk(10, function (Collection $collection) use ($event) { $collection->each(function (EventSettings $model) use ($event) { $this->getEventProcessor()->process($event, $model->settings); }); }); } /** * @return EventProcessor */ public function getEventProcessor(): EventProcessor { return $this->eventProcessor; } /** * @param EventProcessor $eventProcessor * * @return $this */ public function setEventProcessor(EventProcessor $eventProcessor): self { $this->eventProcessor = $eventProcessor; return $this; } }
Регистрируем в провайдере:
<?php namespace App\Providers; use App\Interfaces\Events\SystemEvent; use App\Listeners\SystemEventListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider { /** * The event listener mappings for the application. * * @var array */ protected $listen = [ SystemEvent::class => [ SystemEventListener::class, ], ]; }
В итоге мы получили возможность настраивать события в системе через интерфейс. Включать и выключать обработчики без изменения кода. Новые модули системы без дополнительных вмешательств могут добавлять свои события и/или обработчики.
После небольшого обучения все это было передано пользователям админки, что высвободило дополнительное рабочее время.
И еще немного кода.
Проверка условий и маппинг параметров:
<?php namespace App\Interfaces\Services; /** * Interface for service to filter data (from HUB) * @package App\Interfaces\Services */ interface Filter { public const CONDITION_EQUAL = '='; public const CONDITION_MORE = '>'; public const CONDITION_LESS = '<'; public const CONDITION_NOT = '!'; public const CONDITION_BETWEEN = 'between'; public const CONDITION_IN = 'in'; public const CONDITION_EMPTY = 'empty'; /** * Filter data * * @param array $filter * @param array $data * * @return array */ public function filter(array $filter, array $data): array; /** * Check conditions * * @param array $conditions * @param array $data * * @return bool */ public function check(array $conditions, array $data): bool; }
<?php namespace App\Services; use Illuminate\Support\Arr; use App\Interfaces\Services\Filter as IFilter; /** * Service to filter data by conditions * @package App\Services */ class Filter implements IFilter { /** * Filter data * * @param array $filter * @param array $data * * @return array */ public function filter(array $filter, array $data): array { if (!empty($filter)) { foreach ($filter as $condition) { $field = $condition['field'] ?? null; if (empty($field)) { continue; } $operation = $condition['operation'] ?? null; $value1 = $condition['value1'] ?? null; $value2 = $condition['value2'] ?? null; $success = $condition['success'] ?? null; $filterResult = $condition['result'] ?? null; $value = Arr::get($data, $field, ''); if ($field !== null && $this->checkCondition($value, $operation, $value1, $value2)) { return $success !== null ? $this->filter($success, $data) : $filterResult; } } } return []; } /** * Check condition * * @param $value * @param $condition * @param $value1 * @param $value2 * * @return bool */ protected function checkCondition($value, $condition, $value1, $value2): bool { $result = false; $value = \is_string($value) ? mb_strtolower($value) : $value; $value1 = \is_string($value1) ? mb_strtolower($value1) : $value1; if ($value2 !== null) { $value2 = \is_string($value2) ? mb_strtolower($value2) : $value2; } $conditions = explode('|', $condition); $invert = \in_array(self::CONDITION_NOT, $conditions); $conditions = array_filter($conditions, function ($item) { return $item !== self::CONDITION_NOT; }); $condition = implode('|', $conditions); switch ($condition) { case self::CONDITION_EQUAL: $result = ($value == $value1); break; case self::CONDITION_IN: $result = \in_array($value, (array)$value1); break; case self::CONDITION_LESS: $result = ($value < $value1); break; case self::CONDITION_MORE: $result = ($value > $value1); break; case self::CONDITION_MORE . '|' . self::CONDITION_EQUAL: case self::CONDITION_EQUAL . '|' . self::CONDITION_MORE: $result = ($value >= $value1); break; case self::CONDITION_LESS . '|' . self::CONDITION_EQUAL: case self::CONDITION_EQUAL . '|' . self::CONDITION_LESS: $result = ($value <= $value1); break; case self::CONDITION_BETWEEN: $result = (($value >= $value1) && ($value <= $value2)); break; case self::CONDITION_EMPTY: $result = empty($value); break; } return $invert ? !$result : $result; } /** * Check conditions * * @param array $conditions * @param array $data * * @return bool */ public function check(array $conditions, array $data): bool { $result = true; if (!empty($conditions)) { foreach ($conditions as $condition) { $field = $condition['param'] ?? null; if (empty($field)) { continue; } $operation = $condition['condition'] ?? null; $value1 = $condition['value'] ?? null; $value2 = $condition['value2'] ?? null; $value = Arr::get($data, $field, ''); $result &= $this->checkCondition($value, $operation, $value1, $value2); } } return $result; } }
<?php namespace App\Interfaces\Services; /** * Interface for service to map params * @package App\Interfaces\Services */ interface FieldMapper { /** * Map * * @param array $map * @param array $data * * @return array */ public function map(array $map, array $data): array; }
<?php namespace App\Services; use Illuminate\Support\Arr; use App\Interfaces\Services\FieldMapper as IFieldMapper; /** * Params/fields mapper (by HUB) * @package App\Services */ class FieldMapper implements IFieldMapper { /** * Map * * @param array $map * @param array $data * * @return array */ public function map(array $map, array $data): array { $result = []; foreach ($map as $from => $to) { $to = (array)$to; if (!empty($to['param']) && ($value = Arr::get($data, $to['param'])) !== null) { Arr::set($result, $from, $value); } elseif ($to['value'] !== '') { Arr::set($result, $from, Arr::get($data, $to['value'], isset($to['value_as_param']) && $to['value_as_param'] ? '' : $to['value'])); } } return $result; }