Die Übersetzung des Artikels wurde für Studenten des Fachkurses "Framework Laravel" vorbereitet. 

Einführung
Dieser Artikel befasst sich mit den Grundlagen der Erstellung von Event-CQRS-Systemen in der PHP-Sprache und im Laravel-Framework. Es wird davon ausgegangen, dass Sie mit dem Entwicklungsschema über den Befehlsbus vertraut sind und eine Vorstellung von Ereignissen haben (insbesondere die Veröffentlichung von Ereignissen für eine Reihe von Listenern). Um dieses Wissen aufzufrischen, können Sie den Laracasts-Dienst verwenden. Darüber hinaus wird davon ausgegangen, dass Sie das CQRS-Prinzip genau kennen. Wenn nicht, empfehle ich dringend, zwei Vorträge 
anzuhören : 
Mathias Verraes Workshop zur Ereignisgenerierung und 
Greg Youngs CQRS und Ereignisgenerierung .
Verwenden Sie den hier angegebenen Code nicht in Ihren Projekten! Es ist eine Lernplattform zum Verständnis der Ideen hinter CQRS. Dieser Code kann nicht als zuverlässig bezeichnet werden, er ist schlecht getestet, und außerdem programmiere ich selten Schnittstellen, sodass es viel schwieriger sein wird, einzelne Teile des Codes zu ändern. Ein viel besseres Beispiel für ein CQRS-Paket, das Sie verwenden können, ist 
Broadway, entwickelt von Qandidate Lab . Dies ist sauberer, lose gekoppelter Code. Einige Abstraktionen machen jedoch nicht ganz klar, ob Sie noch nie auf Ereignissysteme gestoßen sind.
Und das letzte - mein Code ist mit Ereignissen und dem Laravel-Befehlsbus verbunden. Ich wollte sehen, wie der Code in Laravel aussehen würde (ich verwende dieses Framework normalerweise für Projekte kleiner Agenturen). Rückblickend denke ich jedoch, dass ich meine eigenen Implementierungen erstellen sollte. Ich hoffe, dass mein Code auch für diejenigen klar ist, die keine Frameworks verwenden.
Auf github befindet sich der Code unter 
https://github.com/scazz/cqrs-tutorial.git , und in unserem Handbuch werden wir seine Komponenten zur Erhöhung der Logik betrachten.
Wir werden ein Erstregistrierungssystem für eine Surfschule erstellen. Mit seiner Hilfe können sich Schulkunden für Klassen anmelden. Für den Aufnahmevorgang formulieren wir folgende Regeln:
- Jede Lektion muss mindestens einen Kunden haben ...
- ... aber nicht mehr als drei.
Eine der beeindruckendsten Funktionen ereignisbasierter CQRS-Systeme ist die Erstellung von Lesemodellen, die für jede vom System benötigte Metrik spezifisch sind. Beispiele für die Projektion von Lesemodellen finden Sie in ElasticSearch. Greg Young hat in seinem Event Store eine themenorientierte Sprache für die Behandlung komplexer Ereignisse implementiert. Der Einfachheit halber wird unsere Leseprojektion jedoch eine Standard-SQL-Datenbank zur Verwendung mit Eloquent sein. Als Ergebnis haben wir eine Tabelle für Klassen und eine für Kunden.
Mit dem Konzept der Ereignisgenerierung können Sie Ereignisse auch offline verarbeiten. In diesem Artikel werde ich mich jedoch (zur Vereinfachung) maximal an die „traditionellen“ Entwicklungsmodelle halten und unsere Leseprojektionen werden in Echtzeit aktualisiert, unmittelbar nachdem die Ereignisse im Repository gespeichert wurden.
Projekteinrichtung und erster Test
git clone https://github.com/scazz/cqrs-tutorial 
Erstellen Sie ein neues Laravel 5-Projekt
 $> laravel new cqrs-tutorial 
Und für den Anfang brauchen wir einen Test. Wir werden den Integrationstest verwenden, der sicherstellt, dass die Registrierung des Kunden für Klassen dazu führt, dass die Lektion in unserem eloquenten Modell erstellt wird.
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 ); } } 
Wir weisen die neue Aktivität einer ID zu, erstellen ein Team, um sich für eine neue Aktivität anzumelden, und weisen Laravel an, sie zu senden. In der Lektionstabelle müssen wir einen neuen Datensatz erstellen, den wir mit dem Eloquent-Modell lesen können. Wir brauchen eine Datenbank, also füllen Sie Ihre .env-Datei richtig aus.
Jedes in unserem Ereignisspeicher aufgezeichnete Ereignis wird an die Wurzel des Aggregats angehängt, die wir einfach als Entität (Entität) bezeichnen - eine Abstraktion für Bildungszwecke sorgt nur für Verwirrung. ID ist eine universelle eindeutige Kennung (UUID). Dem Ereignisspeicher ist es egal, ob das Ereignis für die Lektion oder den Kunden gilt. Er weiß nur, dass es mit einer ID verbunden ist.
Basierend auf den beim Testen festgestellten Fehlern können wir fehlende Klassen erstellen. Zuerst erstellen wir die LessonId-Klasse und dann den BookLesson-Befehl (machen Sie sich noch keine Gedanken über die Handler-Methode, führen Sie den Test einfach weiter aus). Die Lesson-Klasse ist ein Lesemodell außerhalb des Lesson-Namespace. Ein exklusives Lesemodell - die Logik des Themenbereichs wird hier niemals gespeichert. Abschließend müssen wir eine Migration für die Lektionstabelle erstellen.
Um die Klarheit des Codes zu gewährleisten, werde ich die Assertion Verification Library verwenden. Es kann mit dem folgenden Befehl hinzugefügt werden:
 $> composer require beberlei/assert 
Betrachten Sie den Prozess, der mit diesem Befehl initiiert werden soll:
- Validierung: Imperative Befehle können fehlschlagen, und Ereignisse sind bereits aufgetreten und sollten daher nicht fehlschlagen.
- Erstellen Sie ein neues LessonWasBooked-Ereignis (für eine Lektion angemeldet).
- Aktualisieren Sie den Aktivitätsstatus. (Das Datensatzmodell muss den Status des Modells kennen, damit es eine Validierung durchführen kann.)
- Fügen Sie dieses Ereignis dem Stream nicht festgeschriebener Ereignisse hinzu, die im Aktivitätsdatensatzmodell gespeichert sind.
- Speichern Sie den Stream nicht festgeschriebener Ereignisse im Repository.
- Lösen Sie das LessonWasBooked-Ereignis global aus, um alle Leseprojektoren über die Aktualisierung der Lektionstabelle zu informieren.
Zuerst müssen Sie ein Aufzeichnungsmodell für die Lektion erstellen. Wir werden die statische Factory-Methode 
Lesson::bookClientOntoNewLesson() . Es generiert ein neues 
LessonWasOpened Ereignis (die Lektion ist geöffnet), wendet dieses Ereignis auf sich selbst an (legt nur seine ID fest) und fügt das neue Ereignis in Form von 
DomainEventMessage (das Ereignis plus einige Metadaten, die wir beim Speichern im Ereignisspeicher verwenden) zur Liste der nicht 
DomainEventMessage Ereignisse hinzu.
Der Vorgang wird wiederholt, um dem Ereignis einen Client hinzuzufügen. Beim Anwenden des 
ClientWasBookedOntoLesson Ereignisses (der Client wurde in der Lektion registriert) verfolgt das Aufzeichnungsmodell nicht die 
ClientWasBookedOntoLesson , sondern nur die Anzahl der registrierten Clients. Datensatzmodelle müssen keine Kundennamen kennen, um die Konsistenz sicherzustellen.
Die Methoden 
applyLessonWasOpened und 
applyClientWasBookedOntoLesson scheinen 
applyClientWasBookedOntoLesson etwas seltsam. Wir werden sie später verwenden, wenn wir alte Ereignisse reproduzieren müssen, um den Status des Aufzeichnungsmodells zu bilden. Es ist nicht einfach zu erklären, daher gebe ich einen Code, der Ihnen hilft, diesen Prozess zu verstehen. Später werden wir den Code extrahieren, der nicht festgeschriebene Ereignisse verarbeitet und Domänenereignismeldungen generiert.
 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++; } 
Wir können die CQRS-Komponenten aus unserem Aufzeichnungsmodell extrahieren - Fragmente der Klasse, die an der Verarbeitung nicht festgeschriebener Ereignisse beteiligt sind. Sie können die API auch für eine ereignisgenerierte Entität löschen, indem Sie eine sichere Funktion 
apply() erstellen, die das Ereignis akzeptiert, die entsprechende Methode 
applyEventName() DomainEventMessage und der Liste der nicht 
DomainEventMessage Ereignisse ein neues 
DomainEventMessage Ereignis hinzufügt. Die extrahierte Klasse ist ein Detail der CQRS-Implementierung und enthält keine Domänenlogik. Daher können wir einen neuen Namespace erstellen: App \ CQRS:
app/CQRS/EventSourcedEntity.php die Code- app/CQRS/EventSourcedEntity.php 
Damit der Code funktioniert, müssen wir die DomainEventMessage Klasse hinzufügen, bei der es sich um ein einfaches DTO handelt. Sie finden sie in app/CQRS/DomainEventMessage.phpSo haben wir ein System, das Ereignisse für jeden Schreibversuch generiert und Ereignisse verwendet, um die Änderungen aufzuzeichnen, die erforderlich sind, um Invarianten zu verhindern. Der nächste Schritt besteht darin, diese Ereignisse im Store (EventStore) zu speichern. Zunächst muss dieses Ereignis-Repository erstellt werden. Zur Vereinfachung verwenden wir das Eloquent-Modell, eine einfache SQL-Tabelle mit den folgenden Feldern: * 
UUID (um zu wissen, auf welche Entität das Ereignis 
event_payload ) * 
event_payload (serialisierte Nachricht, die alles enthält, was zum 
event_payload des Ereignisses erforderlich ist) * 
event_payload - Zeitstempel, um zu wissen, wann das Ereignis 
event_payload passiert ist. Wenn Sie den Code sorgfältig prüfen, werden Sie feststellen, dass ich zwei Befehle erstellt habe - zum Erstellen und Zerstören unserer Ereignisspeichertabelle:
- php artisan eloquenteventstore: create (App \ CQRS \ EloquentEventStore \ CreateEloquentEventStore)
- php artisan eloquenteventstore: drop (App \ CQRS \ EloquentEventStore \ DropEloquentEventStore) (vergessen Sie nicht, sie zu App \ Console \ Kernel.php hinzuzufügen, damit sie geladen werden).
Es gibt zwei sehr gute Gründe, SQL nicht als Ereignisspeicher zu verwenden: Es implementiert nicht das Nur-Anhängen-Modell (nur das Hinzufügen von Daten, Ereignisse müssen unveränderlich sein) und auch, weil SQL keine ideale Abfragesprache für temporäre Datenbanken ist. Wir programmieren die Schnittstelle, um den Austausch des Ereignisspeichers in nachfolgenden Veröffentlichungen zu erleichtern.
Verwenden Sie das Repository, um Ereignisse zu speichern. Immer wenn 
save() für das Aufzeichnungsmodell aufgerufen wird, speichern wir die Liste der nicht festgeschriebenen Ereignisse im Ereignisspeicher. Zum Speichern von Ereignissen benötigen wir einen Mechanismus für deren Serialisierung und Deserialisierung. Erstellen Sie dazu einen Serializer. Wir benötigen Metadaten wie eine Ereignisklasse (z. B. 
App\School\Lesson\Events\LessonWasOpened ) und eine Ereignisnutzlast (Daten, die zum Rekonstruieren eines Ereignisses benötigt werden).
All dies wird im JSON-Format codiert und dann zusammen mit der UUID der Entität und dem Zeitstempel in unsere Datenbank geschrieben. Wir möchten unsere Lesemodelle nach der Erfassung von Ereignissen aktualisieren, damit das Repository jedes Ereignis nach dem Speichern auslöst. Der Serializer ist für das Schreiben der Ereignisklasse verantwortlich, während das Ereignis für die Serialisierung seiner Nutzdaten verantwortlich ist. Ein vollständig serialisiertes Ereignis sieht ungefähr so aus:
  { class: "App\\School\\Lesson\\Events\\", event: $event->serialize() } 
Da für alle Ereignisse eine Serialisierungs- und Deserialisierungsmethode erforderlich ist, können wir eine 
SerializableEvent Schnittstelle erstellen und einen Hinweis auf den Typ des erwarteten Werts hinzufügen. Aktualisieren Sie unser 
LessonWasOpened Ereignis:
 app/School/Lesson/Events/LessonWasOpened.php class LessonWasOpened implements SerializableEvent { public function serialize() { return array( 'lessonId'=> (string) $this->getLessonId() ); } } 
Erstellen Sie ein 
LessonRepository Repository. Wir können die CQRS-Kernkomponenten später umgestalten und extrahieren.
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()); } } } 
Wenn Sie den Integrationstest erneut 
domain_events und dann die SQL-Tabelle 
domain_events überprüfen, sollten zwei Ereignisse in der Datenbank 
domain_events werden.
Unser letzter Schritt zum erfolgreichen Bestehen des Tests besteht darin, die Sendeereignisse abzuhören und die Projektion des Unterrichtslesemodells zu aktualisieren. Lektionssendungsereignisse werden vom 
LessonProjector abgefangen, der die erforderlichen Änderungen an 
LessonProjection ( 
LessonProjection Modelle der Lektionstabelle) 
LessonProjection :
  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"; } 
Wenn Sie den Test ausführen, sehen Sie, dass ein SQL-Fehler aufgetreten ist:
 Unknown column 'clientName' in 'field list' 
Sobald wir eine Migration erstellen, um 
clientName zur Lektionstabelle hinzuzufügen, bestehen wir den Test erfolgreich. Wir haben die Grundfunktionalität von CQRS implementiert: Teams erstellen Ereignisse, mit denen Lesemodelle generiert werden.
Verbesserung des Lesemodells mit Links
Wir haben einen bedeutenden Meilenstein erreicht, aber das ist noch nicht alles! Bisher unterstützt das Lesemodell nur einen Client (wir haben drei in unseren Domänenregeln angegeben). Die Änderungen, die wir am 
ClientBookedOntoLesson ClientProjector , sind recht einfach: Wir erstellen lediglich ein Client-Projektionsmodell und einen 
ClientProjector , der das 
ClientBookedOntoLesson Ereignis 
ClientBookedOntoLesson . Zunächst aktualisieren wir unseren Test, um die Änderungen widerzuspiegeln, die wir in unserem Lesemodell sehen möchten:
 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); } 
Dies ist eine klare Demonstration, wie einfach es ist, Lesemodelle zu ändern. Bis auf das Ereignis-Repository bleibt alles unverändert. Als Bonus erhalten wir bei Verwendung des Ereignissystems Daten für grundlegende Tests. Wenn Sie den Projektor des Lesemodells ändern, hören wir jedes Ereignis, das jemals in unserem System aufgetreten ist.
Wir reproduzieren diese Ereignisse mit dem neuen Projektor, suchen nach Ausnahmen und vergleichen die Ergebnisse mit früheren Projektionen. Nachdem das System einige Zeit funktioniert hat, haben wir eine ziemlich repräsentative Auswahl an Ereignissen zum Testen unserer Projektoren.
Unser Aufzeichnungsmodell kann derzeit den aktuellen Status nicht laden. Wenn wir der Lektion einen zweiten Client hinzufügen möchten, können wir einfach ein zweites ClientWasAddedToLesson-Ereignis erstellen, aber keinen Schutz gegen Invarianten bieten. Zur besseren Übersichtlichkeit schlage ich vor, einen zweiten Test zu schreiben, der die Aufzeichnung von zwei Kunden pro Lektion simuliert.
 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); } 
Für unser Aufzeichnungsmodell müssen wir eine Methode zum „Laden“ einer Entität implementieren, für die bereits Ereignisse im Ereignisspeicher gelten. Wir können dies erreichen, indem wir jedes Ereignis wiedergeben, das sich auf die UUID der Entität bezieht. Im Allgemeinen ist der Prozess wie folgt:
- Wir erhalten alle relevanten Ereignismeldungen vom Ereignisspeicher.
- Für jede Nachricht erstellen wir das entsprechende Ereignis neu.
- Wir erstellen ein neues Entity-Record-Modell und spielen jedes Ereignis ab.
Im Moment 
BookClientOntoLesson unsere Tests Ausnahmen aus. 
BookClientOntoLesson erstellen wir zunächst den erforderlichen 
BookClientOntoLesson (registrieren Sie einen Client für die Lektion) und verwenden den 
BookLesson Befehl als Vorlage. Die Handler-Methode sieht folgendermaßen aus:
  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; } 
Die Repository-Ladefunktion gibt ein Array neu erstellter Ereignisse zurück. Zu diesem Zweck findet sie zuerst Nachrichten zu Ereignissen im Repository und leitet sie dann an den 
Serializer , um jede Nachricht in ein Ereignis umzuwandeln. 
Serializer erstellt Nachrichten aus Ereignissen, daher müssen wir die Methode 
deserialize() hinzufügen, um die inverse Transformation durchzuführen. Denken Sie daran, dass der 
Serializer an jedes Ereignis übergeben wird, um die Ereignisdaten (z. B. den Clientnamen) zu serialisieren. Wir werden dasselbe tun, um die inverse Transformation durchzuführen, während unsere 
SerializableEvent Schnittstelle mit der 
deserialize() -Methode aktualisiert werden muss. Schauen wir uns den Code an, damit alles passt. Zunächst die 
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; } 
Verwenden der entsprechenden Deserialisierungsfunktion 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); } 
Abschließend verwenden wir die statische Factory-Methode 
deserialize() in 
LessonWasOpened (wir müssen diese Methode jedem Ereignis hinzufügen).
 app/School/Lesson/Events/LessonWasOpened.php public static function deserialize($data) { $lessonId = new LessonId($data->lessonId); return new self($lessonId); } 
Jetzt haben wir ein Array aller Ereignisse, die wir gerade relativ zu unserem Entity-Datensatzmodell reproduziert haben, um den Status in der 
initializeState Methode in 
app/CQRS/EventSouredEntity.phpFühren Sie jetzt unseren Test aus. Bingo!
Tatsächlich haben wir derzeit keinen Test, um die Einhaltung unserer Domain-Regeln zu überprüfen. Schreiben wir ihn also:
 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") ); } 
Bitte beachten Sie, dass wir nur die 
lessonId benötigen. Dieser Test initialisiert den Status der Lektion bei jedem Befehl neu.
Im Moment übertragen wir einfach manuell erstellte 
UUID , während wir sie in Wirklichkeit automatisch generieren möchten. Ich werde das 
Ramsy\UUID Paket verwenden, also installieren wir es mit 
composer :
 $> composer require ramsey/uuid 
Aktualisieren Sie jetzt unsere Tests, um das neue Paket zu verwenden:
 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) ); } 
Jetzt kann der neue Projektentwickler den Code 
App\School\ReadModels , siehe 
App\School\ReadModels , der eine Reihe von eloquenten Modellen enthält, und diese Modelle verwenden, um Änderungen in die Lektionstabelle zu schreiben. Wir können dies verhindern, indem wir eine 
ImmutableModel Klasse erstellen, die die Eloquent Model-Klasse erweitert und die Speichermethode in 
app/CQRS/ReadModelImmutableModel.php .