Знаете, как бывает, задачу надо сделать не хорошо, а быстро, т.к. на нее завязаны деньги, партнеры и много всего другого очень важного для бизнеса. В итоге где-то что-то не продумали, где-то упустили, что-то захардкодили, в общем, все ради скорости. И, вроде, все хорошо, все работает, но…


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


Вот и у меня возникла такая ситуация. Когда-то по-быстрому запилили интеграцию с системой e-mail-маркетинга, а потом посыпались задачи по типу «если пользователь сделал это, необходимо вот это записать вот сюда». Из-за отсутствия наглядности бизнес-процессов возникало их пересечение, данные затирали друг друга, записывалось не то.


Event subsystem / Подсистема событий

Хочу рассказать, как вышли из этой ситуации.


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


Это событие нужно поймать и обработать. Например, отправить письмо, передать данные в CRM или какую-то другую систему. Обработчиков может быть много и их количество будет увеличиваться со временем.


Необходимо связать событие и обработчики. Запускать их нужно как безусловно, так и по некоему условию. Например, если пользователю 20 лет, то ему отправляем письмо одного вида, а если 60, то другого.


Разработка ведется на PHP на Laravel. В этом фреймворке уже есть события и обработчики, на их основе и построена подсистема.


Event subsystem scheme / Подсистема событий - диаграмма

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


<?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; 
    }