📜 ⬆️ ⬇️

Event Generation, CQRS and Laravel

Translation of the article was prepared for students of the professional course "Framework Laravel"





Introduction


This article is devoted to the basics of creating event CQRS-systems in the PHP language and in the Laravel framework. It is assumed that you are familiar with the development scheme using the command bus and have an idea of ​​events (in particular, the publication of events for an array of listeners). To refresh this knowledge, you can use the Laracasts service. In addition, it is assumed that you have a certain understanding of the CQRS principle. If not, I highly recommend listening to two lectures: Mathias Verraes Workshop on Event Generation and Greg Young's CQRS and Event Generation .

Do not use the code given here in your projects! It is a learning platform for understanding the ideas behind CQRS. This code cannot be called reliable, it is poorly tested, and in addition, I rarely program interfaces, so it will be much more difficult to change individual parts of the code. A much better example of a CQRS package you can use is Broadway, developed by Qandidate Lab . This is clean, loosely coupled code, however, some abstractions make it not entirely clear if you have never come across event systems.

And the last - my code is connected with events and the Laravel command bus. I wanted to see how the code would look in Laravel (I usually use this framework for projects of small agencies), however, looking back, I think that I should create my own implementations. I hope my code will be clear even to those who do not use frameworks.
')
On github, the code is located at https://github.com/scazz/cqrs-tutorial.git , and in our guide we will consider its components for increasing logic.

We will create the initial registration system for the surf school. With its help, school clients can register for classes. For the recording process, we formulate the following rules:


One of the most impressive features of event-based CQRS systems is the creation of reading models specific to each metric required by the system. You'll find examples of projection of reading models in ElasticSearch, and Greg Young has implemented a subject-oriented language in his event store for handling complex events. However, for simplicity, our read projection will be a standard SQL database for use with Eloquent. As a result, we will have one table for classes and one for clients.

The concept of event generation also allows you to process events offline. But in this article I will adhere to the “traditional” development models to the maximum (again for simplicity), and our reading projections will be updated in real time, immediately after the events are stored in the repository.

Project setup and first test


git clone https://github.com/scazz/cqrs-tutorial 

Create a new Laravel 5 project

 $> laravel new cqrs-tutorial 

And for starters, we need a test. We will use the integration test, which will make sure that the client’s registration for classes leads to the fact that the lesson is created in our Eloquent model.

Listing tests / CQRSTest.php:

 use Illuminate\Foundation\Bus\DispatchesCommands; class CQRSTest extends TestCase { use DispatchesCommands; 

 /** *  ,   BookLesson       * @return void */ public function testFiringEventUpdatesReadModel() { $testLessonId = '123e4567-e89b-12d3-a456-426655440000'; $clientName = "George"; $lessonId = new LessonId($testLessonId); $command = new BookLesson($lessonId, $clientName); $this->dispatch($command); $this->assertNotNull(Lesson::find($testLessonId)); $this->assertEquals( Lesson::find($testLessonId)->clientName, $clientName ); } } 

We preassign the new activity to an ID, create a team to sign up for a new activity, and tell Laravel to send it. In the lesson table, we need to create a new record that we can read using the Eloquent model. We need a database, so fill in your .env file properly.

Each event registered in our event repository is attached to the root of the aggregate, which we will simply call the entity (Entity) - an abstraction for educational purposes only adds confusion. ID is a universal unique identifier (UUID). The event store doesn’t care if the event applies to the lesson or the client. He only knows that it is associated with an ID.

Based on the errors identified during testing, we can create missing classes. First, we will create the LessonId class, then the BookLesson command (don’t worry about the handler method yet, just continue to run the test). The Lesson class is a reading model outside the Lesson namespace. An exclusively reading model - the logic of the subject area will never be stored here. In conclusion, we need to create a migration for the occupation table.

To maintain the clarity of the code, I will use the assertion verification library. It can be added with the following command:

 $> composer require beberlei/assert 

Consider the process that should be initiated by this command:

  1. Validation: imperative commands may fail, and events have already occurred and therefore should not fail.
  2. Create a new LessonWasBooked event (signed up for a lesson).
  3. Update the activity status. (The recording model must be aware of the state of the model so that it can perform validation.)
  4. Add this event to the stream of uncommitted events stored in the activity record model.
  5. Save the stream of uncommitted events to the repository.
  6. Raise the LessonWasBooked event globally to inform all reading projectors of the need to update the lesson table.

First you need to create a recording model for the lesson. Lesson::bookClientOntoNewLesson() use the static factory method Lesson::bookClientOntoNewLesson() . It generates a new LessonWasOpened event (the lesson is open), applies this event to itself (just sets its ID), adds the new event to the list of uncommitted events in the form of DomainEventMessage (the event plus some metadata that we use when saving to the event store).

The process is repeated to add a client to the event. When applying the ClientWasBookedOntoLesson event (the client was enrolled in the lesson), the recording model does not track client names, but only the number of registered clients. Record models do not need to know customer names to ensure consistency.

The applyLessonWasOpened and applyClientWasBookedOntoLesson methods may seem a bit odd applyClientWasBookedOntoLesson now. We will use them later when we need to reproduce old events in order to form the state of the recording model. It’s not easy to explain, so I’ll give a code that will help you understand this process. Later we will extract the code that processes uncommittedEvents and generates domain event messages.

 app/School/Lesson/Lesson.php public function openLesson( LessonId $lessonId ) { /*      ,      ,       */ $this->apply( new LessonWasOpened( $lessonId) ); } protected function applyLessonWasOpened( LessonWasOpened $event ) { $this->lessonId = $event->getLessonId(); $this->numberOfClients = 0; } public function bookClient( $clientName ) { if ($this->numberOfClients >= 3) { throw new TooManyClientsAddedToLesson(); } $this->apply( new ClientBookedOntoLesson( $this->lessonId, $clientName) ); } /** *       — *  ,       *      ,       , *      . */ protected function applyClientBookedOntoLesson( ClientBookedOntoLesson $event ) { $this->numberOfClients++; } 

We can extract the CQRS components from our recording model — fragments of the class involved in processing uncommitted events. We can also clear the API for an event-generated entity by creating a secure apply() function that accepts the event, invokes the appropriate applyEventName() method, and adds a new DomainEventMessage event to the list of uncommitted events. The extracted class is a detail of the CQRS implementation and does not contain domain logic, so we can create a new namespace: App \ CQRS:

Pay attention to the code app/CQRS/EventSourcedEntity.php
For the code to work, we need to add the DomainEventMessage class, which is a simple DTO - it can be found in app/CQRS/DomainEventMessage.php

Thus, we got a system that generates events for each write attempt and uses events to record the changes necessary to prevent invariants. The next step is to store these events in the store (EventStore). First of all, this event repository needs to be created. To simplify, we will use the Eloquent model, a simple SQL table with the following fields: * UUID (to know which entity to apply the event to) * event_payload (serialized message containing everything necessary to recreate the event) * recordedAt - timestamp to know when the event happened. If you carefully review the code, you will see that I created two commands - to create and destroy our event storage table:


There are two very good reasons not to use SQL as an event store: it does not implement the append-only model (only adding data, events must be immutable), and also because SQL is not an ideal query language for temporal databases. We program the interface to facilitate the replacement of the event store in subsequent publications.

To save events, use the repository. Whenever save() is called for the recording model, we save the uncommittedEvents list in the event store. To store events, we need a mechanism for their serialization and deserialization. Create a Serializer for this. We need metadata, such as an event class (for example, App\School\Lesson\Events\LessonWasOpened ) and an event payload (data needed to reconstruct an event).

All this will be encoded in JSON format, and then written to our database along with the entity UUID and timestamp. We want to update our reading models after capturing events, so the repository will trigger every event after saving. The Serializer will be responsible for writing the event class, while the event will be responsible for serializing its payload. A fully serialized event will look something like this:

  { class: "App\\School\\Lesson\\Events\\", event: $event->serialize() } 

Since all events require a serialization and deserialization method, we can create a SerializableEvent interface and add an indication of the type of expected value. Update our LessonWasOpened event:

 app/School/Lesson/Events/LessonWasOpened.php class LessonWasOpened implements SerializableEvent { public function serialize() { return array( 'lessonId'=> (string) $this->getLessonId() ); } } 

Create a LessonRepository repository. We can refactor and extract the core CQRS components later.

app/School/Lesson/LessonRepository.php
 eventStoreRepository = new EloquentEventStoreRepository( new EventSerializer() ); } public function save(Lesson $lesson) { /** @var DomainEventMessage $domainEventMessage */ foreach( $lesson->getUncommittedDomainEvents() as $domainEventMessage ) { $this->eventStoreRepository->append( $domainEventMessage->getId(), $domainEventMessage->getEvent(), $domainEventMessage->getRecordedAt() ); Event::fire($domainEventMessage->getEvent()); } } } 

If you run the integration test again, and then check the domain_events SQL table, you should see two events in the database.

Our final step in successfully passing the test is listening to the broadcast events and updating the projection of the Lesson reading model. Lesson broadcast events will be intercepted by the LessonProjector , which will apply the necessary changes to LessonProjection (Eloquent models of the lesson table):

  app/School/Lesson/Projections/LessonProjector.php class LessonProjector { public function applyLessonWasOpened( LessonWasOpened $event ) { $lessonProjection = new LessonProjection(); $lessonProjection->id = $event->getLessonId(); $lessonProjection->save(); } public function subscribe(Dispatcher $events) { $fullClassName = self::class; $events->listen( LessonWasOpened::class, $fullClassName.'@applyLessonWasOpened'); } }  app/School/Lesson/Projections/LessonProjection.php class LessonProjection extends Model { public $timestamps = false; protected $table = "lessons"; } 

If you run the test, you will see that an SQL error has occurred:

 Unknown column 'clientName' in 'field list' 

As soon as we create a migration to add clientName to the lesson table, we will successfully pass the test. We have implemented the basic functionality of CQRS: the teams create events that are used to generate reading models.

Improving the reading model with links


We have reached a significant milestone, but that is not all! So far, the reading model only supports one client (we specified three in our domain rules). The changes we make to the reading model are quite simple: we just create a Client projection model and a ClientProjector that catches the ClientBookedOntoLesson event. First, update our test to reflect the changes we want to see in our reading model:

 tests/CQRSTest.php public function testFiringEventUpdatesReadModel() { $lessonId = new LessonId( (string) \Rhumsaa\Uuid\Uuid::uuid1() ); $clientName = "George"; $command = new BookLesson($lessonId, $clientName); $this->dispatch($command); $lesson = Lesson::find( (string) $lessonId); $this->assertEquals( $lesson->id, (string) $lessonId ); $client = $lesson->clients()->first(); $this->assertEquals($client->name, $clientName); } 

This is a clear demonstration of how easy it is to change reading models. Everything, down to the event repository, remains unchanged. As a bonus, when using the event system, we get data for basic tests - when changing the projector of the reading model, we listen to every event that has ever happened in our system.

We reproduce these events using the new projector, check for exceptions, and compare the results with previous projections. After the system has been working for some time, we will have a fairly representative selection of events for testing our projectors.

Our recording model currently does not have the ability to load the current state. If we want to add a second client to the lesson, we can simply create a second ClientWasAddedToLesson event, but we cannot provide protection against invariants. For greater clarity, I propose to write a second test simulating the recording of two clients per lesson.

 tests/CQRSTest.php public function testLoadingWriteModel() { $lessonId = new LessonId( (string) \Rhumsaa\Uuid\Uuid::uuid1() ); $clientName_1 = "George"; $clientName_2 = "Fred"; $command = new BookLesson($lessonId, $clientName_1); $this->dispatch($command); $command = new BookClientOntoLesson($lessonId, $clientName_2); $this->dispatch($command); $lesson = Lesson::find( (string) $lessonId ); $this->assertClientCollectionContains($lesson->clients, $clientName_1); $this->assertClientCollectionContains($lesson->clients, $clientName_2); } 

For our recording model, we need to implement a method of “loading” an entity for which events already apply to it in the event store. We can achieve this by playing back every event that refers to the entity's UUID. In general terms, the process is as follows:

  1. We receive all relevant event messages from the event store.
  2. For each message, we recreate the corresponding event.
  3. We create a new entity record model and play back each event.

At the moment, our tests throw exceptions, so we will start by creating the necessary BookClientOntoLesson (register a client for the lesson), using the BookLesson command as a template. The handler method will look like this:

  app/School/Lesson/Commands/BookClientOntoLesson.php public function handle(LessonRepository $repository) { /** @var Lesson $lesson */ $lesson = $repository->load($this->lessonId); $lesson->bookClient($this->clientName); $repository->save($lesson); }      : app/School/Lesson/LessonRepository.php public function load(LessonId $id) { $events = $this->eventStoreRepository->load($id); $lesson = new Lesson(); $lesson->initializeState($events); return $lesson; } 

The repository load function returns an array of recreated events. To do this, she first finds messages about events in the repository, and then passes them to the Serializer to convert each message into an event. Serializer creates messages from events, so we need to add the deserialize() method to perform the inverse transform. Recall that the Serializer is passed to each event to serialize the event data (for example, the client name). We will do the same to perform the inverse transformation, while our SerializableEvent interface must be updated using the deserialize() method. Let's look at the code so that everything falls into place. First up is the EventStoreRepository load EventStoreRepository :

 app/CQRS/EloquentEventStore/EloquentEventStoreRepository.php public function load($uuid) { $eventMessages = EloquentEventStoreModel::where('uuid', $uuid)->get(); $events = []; foreach($eventMessages as $eventMessage) { /*       event_payload,        . */ $events[] = $this->eventSerializer->deserialize( json_decode($eventMessage->event_payload)); } return $events; } 

Using the appropriate deserialization function in eventSerializer :

 app/CQRS/Serializer/EventSerializer.php public function serialize( SerializableEvent $event ) { return array( 'class' => get_class($event), 'payload' => $event->serialize() ); } public function deserialize( $serializedEvent ) { $eventClass = $serializedEvent->class; $eventPayload = $serializedEvent->payload; return $eventClass::deserialize($eventPayload); } 

In conclusion, we will use the static factory method deserialize() in LessonWasOpened (we need to add this method to each event)

 app/School/Lesson/Events/LessonWasOpened.php public static function deserialize($data) { $lessonId = new LessonId($data->lessonId); return new self($lessonId); } 

Now we have an array of all the events that we just reproduced relative to our Entity record model for initializing the state in the initializeState method in app/CQRS/EventSouredEntity.php

Now run our test. Bingo!
In fact, at the moment we do not have a test to verify compliance with our domain rules, so let's write it:

 tests/CQRSTest.php public function testMoreThan3ClientsCannotBeAddedToALesson() { $lessonId = new LessonId( (string) \Rhumsaa\Uuid\Uuid::uuid1() ); $this->dispatch( new BookLesson($lessonId, "bob") ); $this->dispatch( new BookClientOntoLesson($lessonId, "george") ); $this->dispatch( new BookClientOntoLesson($lessonId, "fred") ); $this->setExpectedException( TooManyClientsAddedToLesson::class ); $this->dispatch( new BookClientOntoLesson($lessonId, "emma") ); } 

Please note that we only need lessonId - this test reinitializes the state of the lesson during each command.

At the moment, we simply transfer manually created UUID , whereas in reality we want to generate them automatically. I am going to use the Ramsy\UUID package, so let's install it with composer :

 $> composer require ramsey/uuid 

Now update our tests to use the new package:

 tests/CQRSTest.php public function testEntityCreationWithUUIDGenerator() { $lessonId = new LessonId( (string) \Rhumsaa\Uuid\Uuid::uuid1() ); $this->dispatch( new BookLesson($lessonId, "bob") ); $this->assertInstanceOf( Lesson::class, Lesson::find( (string) $lessonId) ); } 

Now the new project developer can look at the code, see App\School\ReadModels , which contains a set of Eloquent models, and use these models to write changes to the lesson table. We can prevent this by creating an ImmutableModel class that extends the Eloquent Model class and overrides the save method in app/CQRS/ReadModelImmutableModel.php .

Source: https://habr.com/ru/post/461899/


All Articles