Génération d'événements, CQRS et Laravel

La traduction de l'article a été préparée pour les étudiants du cours professionnel "Framework Laravel"





Présentation


Cet article est consacré aux bases de la création de systèmes d'événements CQRS dans le langage PHP et dans le framework Laravel. Il est supposé que vous connaissez le schéma de développement utilisant le bus de commande et que vous avez une idée des événements (en particulier, la publication d'événements pour un tableau d'écouteurs). Pour actualiser ces connaissances, vous pouvez utiliser le service Laracasts. De plus, on suppose que vous avez une certaine compréhension du principe CQRS. Sinon, je recommande fortement d'écouter deux conférences: Mathias Verraes Workshop on Event Generation et Greg Young's CQRS and Event Generation .

N'utilisez pas le code donné ici dans vos projets! Il s'agit d'une plateforme d'apprentissage pour comprendre les idées derrière le CQRS. Ce code ne peut pas être qualifié de fiable, il est mal testé et, en outre, je programme rarement des interfaces, il sera donc beaucoup plus difficile de modifier des parties individuelles du code. Broadway, développé par Qandidate Lab, est un bien meilleur exemple de package CQRS que vous pouvez utiliser. Il s'agit d'un code propre et faiblement couplé, cependant, certaines abstractions ne rendent pas tout à fait clair si vous n'avez jamais rencontré de systèmes d'événements.

Et le dernier - mon code est lié aux événements et au bus de commande Laravel. Je voulais voir à quoi ressemblerait le code dans Laravel (j'utilise habituellement ce cadre pour des projets de petites agences), mais en y repensant, je pense que je devrais créer mes propres implémentations. J'espère que mon code sera clair même pour ceux qui n'utilisent pas de frameworks.

Sur github, le code est situé sur https://github.com/scazz/cqrs-tutorial.git , et dans notre guide, nous considérerons ses composants pour augmenter la logique.

Nous allons créer un premier système d'inscription pour une école de surf. Avec son aide, les clients des écoles peuvent s'inscrire aux cours. Pour le processus d'enregistrement, nous formulons les règles suivantes:

  • Chaque leçon doit avoir au moins un client ...
  • ... mais pas plus de trois.

L'une des caractéristiques les plus impressionnantes des systèmes CQRS basés sur les événements est la création de modèles de lecture spécifiques à chaque métrique requise du système. Vous trouverez des exemples de projection de modèles de lecture dans ElasticSearch, et Greg Young a implémenté un langage orienté sujet dans son magasin d'événements pour gérer des événements complexes. Cependant, pour plus de simplicité, notre projection en lecture sera une base de données SQL standard à utiliser avec Eloquent. Par conséquent, nous aurons une table pour les classes et une pour les clients.

Le concept de génération d'événements vous permet également de traiter des événements hors ligne. Mais dans cet article, j'adhérerai au maximum aux modèles de développement «traditionnels» (encore une fois, pour simplifier), et nos projections de lecture seront mises à jour en temps réel, immédiatement après le stockage des événements dans le référentiel.

Configuration du projet et premier test


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

Créer un nouveau projet Laravel 5

 $> laravel new cqrs-tutorial 

Et pour commencer, nous avons besoin d'un test. Nous utiliserons le test d'intégration, qui veillera à ce que l'inscription d'un client aux cours mène au fait que la leçon est créée dans notre modèle Eloquent.

Liste des 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 ); } } 

Nous préaffectons la nouvelle activité à un identifiant, créons une équipe pour vous inscrire à une nouvelle activité et demandons à Laravel de l'envoyer. Dans le tableau des leçons, nous devons créer un nouvel enregistrement que nous pouvons lire en utilisant le modèle Eloquent. Nous avons besoin d'une base de données, alors remplissez correctement votre fichier .env.

Chaque événement enregistré dans notre magasin d'événements s'attache à la racine de l'agrégat, que nous appellerons simplement l'entité (Entité) - une abstraction à des fins éducatives ne fait qu'ajouter à la confusion. ID est un identifiant unique universel (UUID). Le magasin d'événements ne se soucie pas si l'événement s'applique à la leçon ou au client. Il sait seulement qu'il est associé à une pièce d'identité.

Sur la base des erreurs identifiées lors des tests, nous pouvons créer des classes manquantes. Tout d'abord, nous allons créer la classe LessonId, puis la commande BookLesson (ne vous inquiétez pas encore de la méthode du gestionnaire, continuez simplement d'exécuter le test). La classe Lesson est un modèle de lecture en dehors de l'espace de noms Lesson. Un modèle de lecture exclusif - la logique du sujet ne sera jamais stockée ici. En conclusion, nous devrons créer une migration pour la table des leçons.

Pour conserver la clarté du code, j'utiliserai la bibliothèque de vérification des assertions. Il peut être ajouté avec la commande suivante:

 $> composer require beberlei/assert 

Considérez le processus qui doit être lancé par cette commande:

  1. Validation: les commandes impératives peuvent échouer et des événements ont déjà eu lieu et ne devraient donc pas échouer.
  2. Créez un nouvel événement LessonWasBooked (inscrit à une leçon).
  3. Mettez à jour l'état de l'activité. (Le modèle d'enregistrement doit connaître l'état du modèle pour pouvoir effectuer la validation.)
  4. Ajoutez cet événement au flux d'événements non validés stockés dans le modèle d'enregistrement d'activité.
  5. Enregistrez le flux d'événements non validés dans le référentiel.
  6. Déclenchez l'événement LessonWasBooked globalement pour informer tous les projecteurs de lecture de mettre à jour la table des cours.

Vous devez d'abord créer un modèle d'enregistrement pour la leçon. Nous utiliserons la méthode d'usine statique Lesson::bookClientOntoNewLesson() . Il génère un nouvel événement LessonWasOpened (la leçon est ouverte), applique cet événement à lui-même (définit simplement son ID), ajoute le nouvel événement à la liste des événements non DomainEventMessage sous la forme de DomainEventMessage (l'événement plus quelques métadonnées que nous utilisons lors de l'enregistrement dans le magasin d'événements).

Le processus est répété pour ajouter un client à l'événement. Lors de l'application de l'événement ClientWasBookedOntoLesson (le client était inscrit à la leçon), le modèle d'enregistrement ne suit pas les noms des clients, mais uniquement le nombre de clients enregistrés. Les modèles d'enregistrement n'ont pas besoin de connaître les noms des clients pour garantir la cohérence.

Les méthodes applyLessonWasOpened et applyClientWasBookedOntoLesson peuvent sembler un peu étranges en applyClientWasBookedOntoLesson moment. Nous les utiliserons plus tard lorsque nous aurons besoin de reproduire d'anciens événements afin de former l'état du modèle d'enregistrement. Ce n'est pas facile à expliquer, donc je vais vous donner un code qui vous aidera à comprendre ce processus. Plus tard, nous allons extraire le code qui traite les événements non validés et génère des messages d'événement de domaine.

 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++; } 

Nous pouvons extraire les composants CQRS de notre modèle d'enregistrement - fragments de la classe impliqués dans le traitement des événements non validés. Nous pouvons également effacer l'API d'une entité générée par événement en créant une fonction secure apply() qui accepte l'événement, appelle la méthode applyEventName() correspondante et ajoute un nouvel événement DomainEventMessage à la liste des événements non DomainEventMessage . La classe extraite est un détail de l'implémentation CQRS et ne contient pas de logique de domaine, nous pouvons donc créer un nouvel espace de noms: App \ CQRS:

Faites attention au code app/CQRS/EventSourcedEntity.php
Pour que le code fonctionne, nous devons ajouter la classe DomainEventMessage , qui est un simple DTO - elle peut être trouvée dans app/CQRS/DomainEventMessage.php

Ainsi, nous avons un système qui génère des événements pour chaque tentative d'écriture et utilise des événements pour enregistrer les changements nécessaires pour empêcher les invariants. L'étape suivante consiste à enregistrer ces événements dans le magasin (EventStore). Tout d'abord, ce référentiel d'événements doit être créé. Pour simplifier, nous utiliserons le modèle Eloquent, une simple table SQL avec les champs suivants: * UUID (pour savoir à quelle entité appliquer l'événement) * event_payload (message sérialisé contenant tout le nécessaire pour recréer l'événement) * event_payload - horodatage pour savoir quand l'événement arrivé. Si vous examinez attentivement le code, vous verrez que j'ai créé deux commandes - pour créer et détruire notre table de stockage d'événements:

  • php artisan eloquenteventstore: créer (App \ CQRS \ EloquentEventStore \ CreateEloquentEventStore)
  • php artisan eloquenteventstore: drop (App \ CQRS \ EloquentEventStore \ DropEloquentEventStore) (n'oubliez pas de les ajouter à App \ Console \ Kernel.php pour qu'ils se chargent).

Il y a deux très bonnes raisons de ne pas utiliser SQL comme magasin d'événements: il n'implémente pas le modèle d'ajout uniquement (uniquement l'ajout de données, les événements doivent être immuables), et aussi parce que SQL n'est pas un langage de requête idéal pour les bases de données temporelles. Nous programmons l'interface pour faciliter le remplacement du magasin d'événements dans les publications suivantes.

Pour enregistrer des événements, utilisez le référentiel. Chaque fois que save() est appelé pour le modèle d'enregistrement, nous enregistrons la liste uncommittedEvents dans le magasin d'événements. Pour stocker des événements, nous avons besoin d'un mécanisme pour leur sérialisation et leur désérialisation. Créez un sérialiseur pour cela. Nous aurons besoin de métadonnées, telles qu'une classe d'événements (par exemple, App\School\Lesson\Events\LessonWasOpened ) et une charge utile d'événement (données nécessaires pour reconstruire un événement).

Tout cela sera encodé au format JSON, puis écrit dans notre base de données avec l'UUID et l'horodatage de l'entité. Nous voulons mettre à jour nos modèles de lecture après la capture des événements, de sorte que le référentiel déclenchera chaque événement après l'enregistrement. Le sérialiseur sera responsable de l'écriture de la classe d'événements, tandis que l'événement sera responsable de la sérialisation de sa charge utile. Un événement entièrement sérialisé ressemblera à ceci:

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

Étant donné que tous les événements nécessitent une méthode de sérialisation et de désérialisation, nous pouvons créer une interface SerializableEvent et ajouter une indication du type de valeur attendue. Mettez à jour notre événement LessonWasOpened :

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

Créez un référentiel LessonRepository . Nous pouvons refactoriser et extraire les composants CQRS de base plus tard.

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()); } } } 

Si vous exécutez à nouveau le test d'intégration, puis vérifiez la domain_events SQL domain_events , vous devriez voir deux événements dans la base de données.

Notre dernière étape pour réussir le test est d'écouter les événements diffusés et de mettre à jour la projection du modèle de lecture de la leçon. Les événements de diffusion de leçon seront interceptés par le LessonProjector , qui appliquera les modifications nécessaires à LessonProjection (modèles éloquents du tableau de leçon):

  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"; } 

Si vous exécutez le test, vous verrez qu'une erreur SQL s'est produite:

 Unknown column 'clientName' in 'field list' 

Dès que nous créerons une migration pour ajouter clientName à la table de cours, nous réussirons le test. Nous avons implémenté la fonctionnalité de base du CQRS: les équipes créent des événements qui sont utilisés pour générer des modèles de lecture.

Amélioration du modèle de lecture avec des liens


Nous avons franchi une étape importante, mais ce n'est pas tout! Jusqu'à présent, le modèle de lecture ne prend en charge qu'un seul client (nous en avons spécifié trois dans nos règles de domaine). Les modifications que nous apportons au modèle de lecture sont assez simples: nous créons simplement un modèle de projection Client et un ClientProjector qui ClientBookedOntoLesson événement ClientBookedOntoLesson . Tout d'abord, nous mettons à jour notre test pour refléter les changements que nous voulons voir dans notre modèle de lecture:

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

Il s'agit d'une démonstration claire de la facilité avec laquelle il est possible de changer de modèle de lecture. Tout, jusqu'au référentiel d'événements, reste inchangé. En prime, lorsque nous utilisons le système d'événements, nous obtenons des données pour les tests de base - lors du changement du projecteur du modèle de lecture, nous écoutons chaque événement qui s'est jamais produit dans notre système.

Nous reproduisons ces événements à l'aide du nouveau projecteur, vérifions les exceptions et comparons les résultats avec les projections précédentes. Après que le système fonctionne depuis un certain temps, nous aurons une sélection assez représentative d'événements pour tester nos projecteurs.

Notre modèle d'enregistrement n'a actuellement pas la capacité de charger l'état actuel. Si nous voulons ajouter un deuxième client à la leçon, nous pouvons simplement créer un deuxième événement ClientWasAddedToLesson, mais nous ne pouvons pas fournir de protection contre les invariants. Pour plus de clarté, je propose d'écrire un deuxième test simulant l'enregistrement de deux clients par leçon.

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

Pour notre modèle d'enregistrement, nous devons implémenter une méthode de «chargement» d'une entité pour laquelle des événements lui sont déjà appliqués dans le magasin d'événements. Nous pouvons y parvenir en lisant chaque événement faisant référence à l'UUID de l'entité. De manière générale, le processus est le suivant:

  1. Nous recevons tous les messages d'événements pertinents de la boutique d'événements.
  2. Pour chaque message, nous recréons l'événement correspondant.
  3. Nous créons un nouveau modèle d'enregistrement d'entité et lisons chaque événement.

Pour le moment, nos tests génèrent des exceptions, nous allons donc commencer par créer la BookClientOntoLesson nécessaire (enregistrer un client pour la leçon), en utilisant la commande BookLesson comme modèle. La méthode du gestionnaire ressemblera à ceci:

  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; } 

La fonction de chargement du référentiel renvoie un tableau d'événements recréés. Pour ce faire, elle trouve d'abord des messages sur les événements dans le référentiel, puis les transmet au Serializer pour convertir chaque message en événement. Serializer crée des messages à partir d'événements, nous devons donc ajouter la méthode deserialize() pour effectuer la transformation inverse. Rappelez-vous que le Serializer est transmis à chaque événement pour sérialiser les données d'événement (par exemple, le nom du client). Nous ferons de même pour effectuer la transformation inverse, tandis que notre interface SerializableEvent doit être mise à jour à l'aide de la méthode deserialize() . Regardons le code pour que tout se mette en place. Le premier est la EventStoreRepository chargement 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; } 

Utilisation de la fonction de désérialisation appropriée dans 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); } 

En conclusion, nous utiliserons la méthode d'usine statique LessonWasOpened ( deserialize() dans LessonWasOpened (nous devons ajouter cette méthode à chaque événement)

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

Nous avons maintenant un tableau de tous les événements que nous venons de reproduire par rapport à notre modèle d'enregistrement d'entité pour initialiser l'état dans la méthode initializeState dans app/CQRS/EventSouredEntity.php

Maintenant, lancez notre test. Bingo!
En fait, pour le moment, nous n'avons pas de test pour vérifier la conformité avec nos règles de domaine, alors écrivons-le:

 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") ); } 

Veuillez noter que nous n'avons besoin que de lessonId - ce test réinitialise l'état de la leçon lors de chaque commande.

Pour le moment, nous transférons simplement les UUID créés manuellement, alors qu'en réalité nous voulons les générer automatiquement. Je vais utiliser le Ramsy\UUID , alors installons-le avec composer :

 $> composer require ramsey/uuid 

Maintenant, mettez à jour nos tests pour utiliser le nouveau 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) ); } 

Maintenant, le nouveau développeur de projet peut consulter le code, voir App\School\ReadModels , qui contient un ensemble de modèles Eloquent, et utiliser ces modèles pour écrire des modifications dans le tableau des leçons. Nous pouvons éviter cela en créant une classe ImmutableModel qui étend la classe Eloquent Model et remplace la méthode save dans app/CQRS/ReadModelImmutableModel.php .

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


All Articles