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.php
So 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.php
Fü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
.