Что такое cqrs php

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

Недавно в подкасте «Цинковый прод» мы с товарищами обсуждали паттерн CQRS/ES и некоторые особенности её реализации в Elixir. Т.к. я в работе использую Laravel, грех было не покопаться в интернетах и не найти как же можно потягать этот подход в экосистеме данного фреймворка.

Всех приглашаю под кат, постарался максимально тезисно описать тему.

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

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

В бой

Laravel event projector — библиотека CQRS/ES для Laravel
Larabank — репозиторий с реализованным CQRS/ES подходом. Его и возьмем на пробу.

Читайте также:  Tech Decode

Конфигурация библиотеки подскажет куда смотреть и расскажет, что это такое. Смотрим файл event-projector.php. Из необходимого для описания работы:

  • projectors — регистрируем проекторы;
  • reactors — регистрируем реакторы. Реактор — в данной библиотеке добавляет сайд-эффекты в обработку событий, например в этом репозитории, если три раза попытаться превысить лимит снятия средств, то пишется событие MoreMoneyNeeded и отправляется письмо пользователю о его финансовых трудностях;
  • replay_chunk_size — размер чанка повтора. Одна из фич ES — возможность восстановить историю по событиям. Laravel event projector подготовился к утечке памяти во время такой операции с помощью данной настройки.

Обращаем внимание на миграции. Кроме стандартных Laravel таблиц имеем

  • stored_events — основная ES таблица с несколькими колонками неструктурированных данных под мета данные событий, строкой храним типы событий. Важная колонка aggregate_uuid — хранит uuid агрегата, для получения всех событий относящихся к нему;
  • accounts — таблица проектора счетов пользователя, необходима для быстрой отдачи актуальных данных о состоянии баланса;
  • transaction_counts — таблица проектора количества транзакций пользователя, необходима для быстрой отдачи количества совершенных транзакций.

А теперь предлагаю отправиться в путь вместе с запросом на создание нового счета.

Создание счета

Стандартный resource роутинг описывает AccountsController. Нас интересует метод store

public function store(Request $request) < $newUuid = Str::uuid(); // Обращаемся к агрегату, сообщаем ему uuid событий // которые в него должны входить AccountAggregateRoot::retrieve($newUuid) // Добавляем в массив событий на отправку событие создания нового счета ->createAccount($request->name, auth()->user()->id) // Отправляем массив событий на отправку в очередь на запись ->persist(); return back(); >

AccountAggregateRoot наследует библиотечный AggregateRoot. Посмторим на методы, которые вызывал контроллер.

// Берем 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 как stored_event_model в нашем случае StoredEvent

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'))); >); >

Проекторы AccountProjector и TransactionCountProjector реализуют Projector поэтому реагировать на события будут синхронно вместе с их записью.

Ок, счет создали. Предлагаю рассмотреть как же клиент будет его читать.

Отображение счета

// Идем в таблицу `accounts` и берем счет по id public function index() < $accounts = Account::where('user_id', Auth::user()->id)->get(); return view('accounts.index', compact('accounts')); >

Если проектор счетов реализует интерфейс QueuedProjector, то пользователь ничего не увидит пока событие не будет обработано по очереди.

Напоследок изучим, как работает пополнение и снятие денег со счета.

Пополнение и снятие

Снова смотрим в контроллер AccountsController:

// Получаем события с 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(); >
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; >
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. Концепция очень интересная, но не без особенностей. Прежде, чем внедрять помните о:

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

Буду рад замеченным ошибкам.

Источник

Going into CQRS with PHP

Going into CQRS with PHP

John sits alone behind the computer in the office. The only light is the lamp and laptop on his desk. He feels exhausted, however he knows that he must find the cause of the problem.

After another hour of debugging, John randomly reproduced the bug and he finally got it.
In his code he has reused Product Service to retrieve Product from the database.
What he did not knew however, that this code was changing and saving products amount of views, whenever somebody called it.
John had bad luck, as his team mate, introduced a bug in this code which stored incorrect value.

How many times you had to debug the code deeply, when function that seems to be obvious in what it does, was actually doing something unexpected?

The CQRS comes to help us in such situations. So we can reason more about the code, as the code becomes more clear and simple.

Enter the Command Query Responsibility Separation (CQRS)

The CQRS in his principles defines distinction between queries and commands.
Queries are way to fetch the data and Commands to modify it.

This simple distinction allows for agreement between developers, that they are safe to use
queries as many times as they want without side effects being done.

Queries and Commands are identifiable, which means we know what specific query or command is supposed to do. This has huge advantage over CRUD, as it allows
to catch the intention of the user.
If user wants to change his email address. Then we may ask only for the email and as a result, send him specific notification to confirm his new email address.
From the query side, we can provide queries for specific views. This allow us to know where is it used and what decision it precede. For changing an email, it may be enough to retrieve only the current email, instead of full user details.

CQRS in Practice

Symfony CQRS

To install Ecotone support for Symfony, do following:

Laravel CQRS

To install Ecotone support for Laravel, do following:

Registering Command

Let’s define our first Command that will change the email address.

class ChangeEmailAddressCommand < public function __construct(private string $userId, private string $email) <>public function getUserId(): string < return $this->userId; > public function getEmail(): string < return $this->email; > >

The Command Handler is a place where we actually handle the defined command.

This is how we correlate the Command with the Handler.
Ecotone is looking at the first type hint in the method declaration to know, which command should this handler use.

To send this command, we will use CommandBus, that we will inject into our HTTP Controller.

class PersonController < public function __construct(private CommandBus $commandBus) <>public function changeEmailAddress(Request $request) < $userId = $request->get('userId'); $email = $request->get('email'); $this->commandBus->send(new ChangeEmailAddress($userId, $email)); return new Response(); > >

The Command Bus is automatically registered within your Dependency Container. He is aware of the routing to specific handlers, so all you need to do is to send the command.

Above Controller is pseudo code of course, adjusting it to Symfony or Laravel should be pretty straightforward however.

Registering Query

Let’s register Query that will return Shipping Address of the user.

class GetUserShippingAddressQuery < public function __construct(private string $userId) <>public function getUserId(): string < return $this->userId; > >

And now Query Handler that will handle our GetUserShippingAddressQuery.

Ecotone allows for full flexibility, you may define multiple query/command handlers within same class, if you feel a need.
This helps in keeping your logic together and avoiding code boilerplate.

As we have registered our Query Handler using #[QueryHandler] annotation, we may now send the query.

class PersonController < public function __construct(private QueryBus $queryBus) <>public function getShippingAddress(Request $request) < $userId = $request->get('userId'); $shippingAddress = $this->queryBus->send(new GetUserShippingAddressQuery($userId)); // serialize if needed and return response return new Response($shippingAddress); > >

Now we are calling the QueryBus and returning the billing address.

Powerful capabilities, yet simple usage

When you will start using Ecotone, you will find yourself doing much less configuration,
in result code will become much easier to read and understand.

Ecotone follows new trend in programming, that aims for keeping the business logic clean of framework. In most of the cases, you will be using PHP 8 Attributes only, and Ecotone will handle firing and wiring it all together.

You may read more about Command Handlers and Query Handlers in the official Ecotone’s documentation.

Sign up for more like this.

Building Blocks: Exploring Aggregates, Sagas, Event Sourcing

Building Blocks: Exploring Aggregates, Sagas, Event Sourcing

Learn how building blocks enable developers to build domain-focused applications while abstracting away the complexities of integration.

Revolutionary BOA Framework: Ecotone

Revolutionary BOA Framework: Ecotone

Ecotone will change the way PHP application development is perceived thanks to enabling architecture oriented on the business.

Building Reactive Systems in PHP

Building Reactive Systems in PHP

I believe applications in 2023 and beyond should be able to self-heal, isolate failures so they don’t cascade on other components, and provide us with help to get back on track when unrecoverable error happens. They should help the developer on the design level when adding new features without

Источник

Оцените статью