git clone https://github.com/scazz/cqrs-tutorial
$> laravel new cqrs-tutorial
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 ); } }
$> composer require beberlei/assert
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).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.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++; }
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:app/CQRS/EventSourcedEntity.php
DomainEventMessage
class, which is a simple DTO - it can be found in app/CQRS/DomainEventMessage.php
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: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). { class: "App\\School\\Lesson\\Events\\", event: $event->serialize() }
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() ); } }
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()); } } }
domain_events
SQL table, you should see two events in the database.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"; }
Unknown column 'clientName' in 'field list'
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.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); }
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); }
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; }
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; }
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); }
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); }
initializeState
method in app/CQRS/EventSouredEntity.php
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") ); }
lessonId
- this test reinitializes the state of the lesson during each command.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
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) ); }
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