[Laravel, PHP, Проектирование и рефакторинг] Подсистема событий как способ избавиться от задач по «допилу»

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
27-Июл-2020 21:36

Знаете, как бывает, задачу надо сделать не хорошо, а быстро, т.к. на нее завязаны деньги, партнеры и много всего другого очень важного для бизнеса. В итоге где-то что-то не продумали, где-то упустили, что-то захардкодили, в общем, все ради скорости. И, вроде, все хорошо, все работает, но…
Через какое-то время оказывается, что функционал нужно расширять, а сделать это сложно, не хватает гибкости. За настройками, конечно, обращаются к разработчикам. И, конечно же, это отвлекает от других задач и не покидает ощущение, что время потрачено зря.
Вот и у меня возникла такая ситуация. Когда-то по-быстрому запилили интеграцию с системой 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;
    }

===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_laravel, #_php, #_proektirovanie_i_refaktoring (Проектирование и рефакторинг), #_arhitektura_prilozhenij (архитектура приложений), #_php, #_laravel, #_laravel, #_php, #_proektirovanie_i_refaktoring (
Проектирование и рефакторинг
)
Профиль  ЛС 
Показать сообщения:     

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

Текущее время: 23-Ноя 00:09
Часовой пояс: UTC + 5