- PVSM.RU - https://www.pvsm.ru -

Как быстро попробовать CQRS-ES в Laravel или пишем банк на PHP

Как быстро попробовать CQRS-ES в Laravel или пишем банк на PHP - 1
Недавно в подкасте "Цинковый прод [1]" мы с товарищами обсуждали паттерн CQRS/ES и некоторые особенности её реализации в Elixir. Т.к. я в работе использую Laravel, грех было не покопаться в интернетах и не найти как же можно потягать этот подход в экосистеме данного фреймворка.
Всех приглашаю под кат, постарался максимально тезисно описать тему.

Немножко определений

CQRS (Command Query Responsibility Segregation) — выделение в отдельные сущности операции чтения и записи. Например пишем в мастер, читаем из реплики. CQRS. Факты и заблуждения [2] — поможет досконально познать дзен CQRS.
ES (Event Sourcing) — хранение всех изменений состояния какой-либо сущности или набора сущностей.
CQRS/ES — это архитектурный подход при котором мы сохраняем все события изменения состояния какой либо сущности в таблице событий и добавляем к этому агрегат и проектор.
Агрегат — хранит в памяти свойства, необходимые для принятия решений бизнес логики (для ускорения записи), принимает решения (бизнес логика) и публикует события.
Проектор — слушает события и пишет в отдельные таблицы или базы (для ускорения чтения).

Как быстро попробовать CQRS-ES в Laravel или пишем банк на PHP - 2

В бой

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 или пишем банк на PHP - 3

Заключение

Постарался максимально без воды описать процесс "онбординга" в CQRS/ES на Laravel. Концепция
очень интересная, но не без особенностей. Прежде, чем внедрять помните о:

  • eventual consistency;
  • желательно использовать в отдельных доменах DDD, не стоит делать большую систему полностью на этом паттерне;
  • изменения в схеме таблицы событий могут быть очень болезненны;
  • ответственно стоит подойти к выбору гранулярности событий, чем больше будет конкретных событий, тем больше их будет в таблице и большее количество ресурсов будет необходимо на работу с ними.
    Буду рад замеченным ошибкам.

Автор: 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