Знаете, как бывает, задачу надо сделать не хорошо, а быстро, т.к. на нее завязаны деньги, партнеры и много всего другого очень важного для бизнеса. В итоге где-то что-то не продумали, где-то упустили, что-то захардкодили, в общем, все ради скорости. И, вроде, все хорошо, все работает, но…
Через какое-то время оказывается, что функционал нужно расширять, а сделать это сложно, не хватает гибкости. За настройками, конечно, обращаются к разработчикам. И, конечно же, это отвлекает от других задач и не покидает ощущение, что время потрачено зря.
Вот и у меня возникла такая ситуация. Когда-то по-быстрому запилили интеграцию с системой 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\Services\Events;
use App\Services\FieldMapper;
use App\Interfaces\Services\Filter;
use App\Interfaces\Actions\ActionPool;
use App\Interfaces\Events\SystemEvent;
use App\Interfaces\Events\EventProcessor as IEventProcessor;
/**
* event processor
* @package App\Services\Events
*/
class EventProcessor implements IEventProcessor
{
/** @var ActionPool */
private $actionPool;
/** @var Filter */
private $filter;
/** @var FieldMapper */
private $fieldMapper;
public function __construct(ActionPool $actionPool, Filter $filter, FieldMapper $fieldMapper)
{
$this->setActionPool($actionPool)->setFilter($filter)->setFieldMapper($fieldMapper);
}
/**
* Process system event
*
* @param SystemEvent $event
* @param array $settings
*/
public function process(SystemEvent $event, array $settings = []): void
{
collect($settings)->each(function (array $action) use ($event) {
$eventData = $event->toArray();
$conditions = $action['conditions'] ?? [];
foreach ($conditions as $index => $condition) {
if (isset($condition['not']) && $condition['not'] == 1) {
$conditions[$index]['condition'] .= '|!';
}
}
if ($this->getFilter()->check($conditions, $eventData)) {
foreach ($action['actions'] as $actionData) {
if (($actionO = $this->getActionPool()->create($actionData['action'])) !== null) {
try {
$freeInput = $actionData['free_input'] ?? [];
foreach ($freeInput as $key => $data) {
unset($freeInput[$key]);
$freeInput[$data['id']] = $data;
}
$data = $this->getFieldMapper()->map(array_merge($actionData['input'] ?? [], $freeInput), $eventData);
foreach ($data as $key => $val) {
$data[$key] = $this->prepareValue($val);
}
$data['event_fields'] = $eventData;
$actionO->run($data);
} catch (\Throwable $ex) {
\Log::critical($ex);
}
} else {
\Log::info('System', ['Can\'t create action ' . $actionData['action']]);
}
}
}
});
}
/**
* Prepare constants
*
* @param $value
*
* @return false|string
*/
protected function prepareValue($value)
{
if ($value === 'current_date') {
return date('Y-m-d H:i:s');
}
return $value;
}
/**
* @return ActionPool
*/
public function getActionPool(): ActionPool
{
return $this->actionPool;
}
/**
* @param ActionPool $actionPool
*
* @return $this
*/
public function setActionPool(ActionPool $actionPool): self
{
$this->actionPool = $actionPool;
return $this;
}
/**
* @return Filter
*/
public function getFilter(): Filter
{
return $this->filter;
}
/**
* @param Filter $filter
*
* @return $this
*/
public function setFilter(Filter $filter): self
{
$this->filter = $filter;
return $this;
}
/**
* @return FieldMapper
*/
public function getFieldMapper(): FieldMapper
{
return $this->fieldMapper;
}
/**
* @param FieldMapper $fieldMapper
*
* @return $this
*/
public function setFieldMapper(FieldMapper $fieldMapper): self
{
$this->fieldMapper = $fieldMapper;
return $this;
}
}
Метод 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;
}