Подсистема событий как способ избавиться от задач по «допилу»

от автора

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

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

Вот и у меня возникла такая ситуация. Когда-то по-быстрому запилили интеграцию с системой 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;      }  

ссылка на оригинал статьи https://habr.com/ru/post/512384/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *