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