- PVSM.RU - https://www.pvsm.ru -
Недавно в подкасте "Цинковый прод [1]" мы с товарищами обсуждали паттерн CQRS/ES и некоторые особенности её реализации в Elixir. Т.к. я в работе использую Laravel, грех было не покопаться в интернетах и не найти как же можно потягать этот подход в экосистеме данного фреймворка.
Всех приглашаю под кат, постарался максимально тезисно описать тему.
CQRS (Command Query Responsibility Segregation) — выделение в отдельные сущности операции чтения и записи. Например пишем в мастер, читаем из реплики. CQRS. Факты и заблуждения [2] — поможет досконально познать дзен CQRS.
ES (Event Sourcing) — хранение всех изменений состояния какой-либо сущности или набора сущностей.
CQRS/ES — это архитектурный подход при котором мы сохраняем все события изменения состояния какой либо сущности в таблице событий и добавляем к этому агрегат и проектор.
Агрегат — хранит в памяти свойства, необходимые для принятия решений бизнес логики (для ускорения записи), принимает решения (бизнес логика) и публикует события.
Проектор — слушает события и пишет в отдельные таблицы или базы (для ускорения чтения).
Laravel event projector [3] — библиотека CQRS/ES для Laravel
Larabank [4] — репозиторий с реализованным CQRS/ES подходом. Его и возьмем на пробу.
Конфигурация библиотеки подскажет куда смотреть и расскажет, что это такое. Смотрим файл event-projector.php [5]. Из необходимого для описания работы:
projectors
— регистрируем проекторы;reactors
— регистрируем реакторы. Реактор — в данной библиотеке добавляет сайд-эффекты в обработку событий, например в этом репозитории, если три раза попытаться превысить лимит снятия средств, то пишется событие MoreMoneyNeeded [6] и отправляется письмо пользователю о его финансовых трудностях;replay_chunk_size
— размер чанка повтора. Одна из фич ES — возможность восстановить историю по событиям. Laravel event projector подготовился к утечке памяти во время такой операции с помощью данной настройки.Обращаем внимание на миграции. Кроме стандартных Laravel таблиц имеем
stored_events
— основная ES таблица с несколькими колонками неструктурированных данных под мета данные событий, строкой храним типы событий. Важная колонка aggregate_uuid
— хранит uuid агрегата, для получения всех событий относящихся к нему;accounts
— таблица проектора счетов пользователя, необходима для быстрой отдачи актуальных данных о состоянии баланса;transaction_counts
— таблица проектора количества транзакций пользователя, необходима для быстрой отдачи количества совершенных транзакций.А теперь предлагаю отправиться в путь вместе с запросом на создание нового счета.
Стандартный resource
роутинг описывает AccountsController [7]. Нас интересует метод store
public function store(Request $request)
{
$newUuid = Str::uuid();
// Обращаемся к агрегату, сообщаем ему uuid событий
// которые в него должны входить
AccountAggregateRoot::retrieve($newUuid)
// Добавляем в массив событий на отправку событие создания нового счета
->createAccount($request->name, auth()->user()->id)
// Отправляем массив событий на отправку в очередь на запись
->persist();
return back();
}
AccountAggregateRoot [8] наследует библиотечный AggregateRoot [9]. Посмторим на методы, которые вызывал контроллер.
// Берем uuid и получаем все его события
public static function retrieve(string $uuid): AggregateRoot
{
$aggregateRoot = (new static());
$aggregateRoot->aggregateUuid = $uuid;
return $aggregateRoot->reconstituteFromEvents();
}
public function createAccount(string $name, string $userId)
{
// Добавляем событие в массив событий на отправку
// у событий, отправляемых в recordThat, есть хуки, но о них позже,
// т.к. на создание счета их нет)
$this->recordThat(new AccountCreated($name, $userId));
return $this;
}
Метод persist
вызывает метод storeMany
у модели указанной в конфигурации event-projector.php [5] как stored_event_model
в нашем случае StoredEvent [10]
public static function storeMany(array $events, string $uuid = null): void
{
collect($events)
->map(function (ShouldBeStored $domainEvent) use ($uuid) {
$storedEvent = static::createForEvent($domainEvent, $uuid);
return [$domainEvent, $storedEvent];
})
->eachSpread(function (ShouldBeStored $event, StoredEvent $storedEvent) {
// Вызываем все проекторы, которые не реализуют интерфейс
// QueuedProjector*
Projectionist::handleWithSyncProjectors($storedEvent);
if (method_exists($event, 'tags')) {
$tags = $event->tags();
}
// Отправляем в очередь джобу обработки и записи события
$storedEventJob = call_user_func(
[config('event-projector.stored_event_job'), 'createForEvent'],
$storedEvent,
$tags ?? []
);
dispatch($storedEventJob->onQueue(config('event-projector.queue')));
});
}
*QueuedProjector [11]
Проекторы AccountProjector [12] и TransactionCountProjector [13] реализуют Projector
поэтому реагировать на события будут синхронно вместе с их записью.
Ок, счет создали. Предлагаю рассмотреть как же клиент будет его читать.
// Идем в таблицу `accounts` и берем счет по id
public function index()
{
$accounts = Account::where('user_id', Auth::user()->id)->get();
return view('accounts.index', compact('accounts'));
}
Если проектор счетов реализует интерфейс QueuedProjector [11], то пользователь ничего не увидит пока событие не будет обработано по очереди.
Напоследок изучим, как работает пополнение и снятие денег со счета.
Снова смотрим в контроллер AccountsController [7]:
// Получаем события с uuid агрегата
// в зависимости от запроса вызываем пополнение
// или снятие денег, затем отправляем на запись
public function update(Account $account, UpdateAccountRequest $request)
{
$aggregateRoot = AccountAggregateRoot::retrieve($account->uuid);
$request->adding()
? $aggregateRoot->addMoney($request->amount)
: $aggregateRoot->subtractMoney($request->amount);
$aggregateRoot->persist();
return back();
}
Рассмотрим AccountAggregateRoot [8]
при пополнении счета:
public function addMoney(int $amount)
{
$this->recordThat(new MoneyAdded($amount));
return $this;
}
// Помните говорил о "хуке" в recordThat
// AggregateRoot*?
// В нем вызывается метод apply(ShouldBeStored $event),
// который в свою очередь вызывает метод 'apply' . EventClassName агрегата
// Хук, который срабатывает при обработке `MoneyAdded`
protected function applyMoneyAdded(MoneyAdded $event)
{
$this->accountLimitHitInARow = 0;
$this->balance += $event->amount;
}
*AggregateRoot [9]
при снятии средств:
public function subtractMoney(int $amount)
{
if (!$this->hasSufficientFundsToSubtractAmount($amount)) {
// Пишем событие о попытке снять больше лимита
$this->recordThat(new AccountLimitHit());
// Если слишком много попыток шлем событие, что
// нужно больше золота, на которое реагирует реактор
// и отправляет сообщение пользователю
if ($this->needsMoreMoney()) {
$this->recordThat(new MoreMoneyNeeded());
}
$this->persist();
throw CouldNotSubtractMoney::notEnoughFunds($amount);
}
$this->recordThat(new MoneySubtracted($amount));
}
protected function applyMoneySubtracted(MoneySubtracted $event)
{
$this->balance -= $event->amount;
$this->accountLimitHitInARow = 0;
}
Постарался максимально без воды описать процесс "онбординга" в CQRS/ES на Laravel. Концепция
очень интересная, но не без особенностей. Прежде, чем внедрять помните о:
Автор: xenmayer
Источник [14]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/314705
Ссылки в тексте:
[1] Цинковый прод: https://soundcloud.com/znprod
[2] CQRS. Факты и заблуждения: https://habr.com/ru/post/347908/
[3] Laravel event projector: https://docs.spatie.be/laravel-event-projector/v2/introduction
[4] Larabank: https://github.com/spatie/larabank-event-projector-aggregates
[5] event-projector.php: https://github.com/spatie/larabank-event-projector-aggregates/blob/7e0993e284353be6afd5e81b72bb617ec25f937f/config/event-projector.php
[6] MoreMoneyNeeded: https://github.com/spatie/larabank-event-projector-aggregates/blob/7e0993e284353be6afd5e81b72bb617ec25f937f/app/Domain/Account/Events/MoreMoneyNeeded.php
[7] AccountsController: https://github.com/spatie/larabank-event-projector-aggregates/blob/110c29308f30fdd777655cb75269ff36c411d06e/app/Http/Controllers/AccountsController.php
[8] AccountAggregateRoot: https://github.com/spatie/larabank-event-projector-aggregates/blob/7e0993e284353be6afd5e81b72bb617ec25f937f/app/Domain/Account/AccountAggregateRoot.php
[9] AggregateRoot: https://github.com/spatie/laravel-event-projector/blob/4808924a542a7ba1f1fafeed829ef9983ad2060b/src/AggregateRoot.php
[10] StoredEvent: https://github.com/spatie/laravel-event-projector/blob/4808924a542a7ba1f1fafeed829ef9983ad2060b/src/Models/StoredEvent.php
[11] QueuedProjector: https://github.com/spatie/laravel-event-projector/blob/d7f68f1f4372ba6c4dfcea1b84b74dc596877765/src/Projectors/QueuedProjector.php
[12] AccountProjector: https://github.com/spatie/larabank-event-projector-aggregates/blob/7e0993e284353be6afd5e81b72bb617ec25f937f/app/Domain/Account/Projectors/AccountProjector.php
[13] TransactionCountProjector: https://github.com/spatie/larabank-event-projector-aggregates/blob/7e0993e284353be6afd5e81b72bb617ec25f937f/app/Domain/Account/Projectors/TransactionCountProjector.php
[14] Источник: https://habr.com/ru/post/448000/?utm_campaign=448000
Нажмите здесь для печати.