Event Generation, CQRS y Laravel

Se preparó la traducción del artículo para los estudiantes del curso profesional "Framework Laravel"





Introduccion


Este artículo está dedicado a los conceptos básicos de la creación de sistemas CQRS de eventos en el lenguaje PHP y en el marco Laravel. Se supone que está familiarizado con el esquema de desarrollo que utiliza el bus de comandos y tiene una idea de los eventos (en particular, la publicación de eventos para una variedad de oyentes). Para actualizar este conocimiento, puede usar el servicio Laracasts. Además, se supone que tiene cierta comprensión del principio CQRS. Si no, recomiendo escuchar dos conferencias: Taller de Mathias Verraes sobre Generación de eventos y CQRS y Generación de eventos de Greg Young .

¡No use el código que se proporciona aquí en sus proyectos! Es una plataforma de aprendizaje para comprender las ideas detrás de CQRS. Este código no se puede llamar confiable, está mal probado y, además, rara vez programo interfaces, por lo que será mucho más difícil cambiar partes individuales del código. Un ejemplo mucho mejor de un paquete CQRS que puede usar es Broadway, desarrollado por Qandidate Lab . Este es un código limpio y vagamente acoplado, sin embargo, algunas abstracciones dejan en claro si nunca se ha encontrado con sistemas de eventos.

Y el último: mi código está conectado con eventos y el bus de comando Laravel. Quería ver cómo se vería el código en Laravel (generalmente uso este marco para proyectos de pequeñas agencias), sin embargo, mirando hacia atrás, creo que debería crear mis propias implementaciones. Espero que mi código sea claro incluso para aquellos que no usan frameworks.

En github, el código se encuentra en https://github.com/scazz/cqrs-tutorial.git , y en nuestra guía consideraremos sus componentes para aumentar la lógica.

Crearemos un sistema de registro inicial para una escuela de surf. Con su ayuda, los clientes escolares pueden registrarse para las clases. Para el proceso de grabación, formulamos las siguientes reglas:

  • Cada lección debe tener al menos un cliente ...
  • ... pero no más de tres.

Una de las características más impresionantes de los sistemas CQRS basados ​​en eventos es la creación de modelos de lectura específicos para cada métrica requerida del sistema. Encontrará ejemplos de proyección de modelos de lectura en ElasticSearch, y Greg Young ha implementado un lenguaje orientado a temas en su tienda de eventos para manejar eventos complejos. Sin embargo, por simplicidad, nuestra proyección de lectura será una base de datos SQL estándar para usar con Eloquent. Como resultado, tendremos una tabla para clases y otra para clientes.

El concepto de generación de eventos también le permite procesar eventos fuera de línea. Pero en este artículo me adheriré al máximo a los modelos de desarrollo "tradicionales" (nuevamente por simplicidad), y nuestras proyecciones de lectura se actualizarán en tiempo real, inmediatamente después de que los eventos se almacenen en el repositorio.

Configuración del proyecto y primera prueba


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

Cree un nuevo proyecto de Laravel 5

 $> laravel new cqrs-tutorial 

Y para empezar, necesitamos una prueba. Utilizaremos la prueba de integración, que asegurará que el registro del cliente para las clases conduzca al hecho de que la lección se crea en nuestro modelo Eloquent.

Listado de pruebas / 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 ); } } 

Asignamos previamente la nueva actividad a una ID, creamos un equipo para inscribirse en una nueva actividad y le decimos a Laravel que la envíe. En la tabla de la lección, necesitamos crear un nuevo registro que podamos leer usando el modelo Eloquent. Necesitamos una base de datos, así que complete su archivo .env correctamente.

Cada evento registrado en nuestra tienda de eventos se adjunta a la raíz del agregado, que simplemente llamaremos entidad (Entidad); una abstracción con fines educativos solo agrega confusión. ID es un identificador único universal (UUID). A la tienda de eventos no le importa si el evento se aplica a la lección o al cliente. Solo sabe que está asociado con una identificación.

En función de los errores identificados durante las pruebas, podemos crear clases faltantes. Primero, crearemos la clase LessonId, luego el comando BookLesson (no se preocupe por el método del controlador todavía, simplemente continúe ejecutando la prueba). La clase Lección es un modelo de lectura fuera del espacio de nombres de la Lección. Un modelo de lectura exclusivo: la lógica del área temática nunca se almacenará aquí. En conclusión, necesitaremos crear una migración para la tabla de lecciones.

Para mantener la claridad del código, usaré la biblioteca de verificación de afirmaciones. Se puede agregar con el siguiente comando:

 $> composer require beberlei/assert 

Considere el proceso que debe iniciar este comando:

  1. Validación: los comandos imperativos pueden fallar, y los eventos ya han ocurrido y, por lo tanto, no deberían fallar.
  2. Cree un nuevo evento LessonWasBooked (se inscribió en una lección).
  3. Actualiza el estado de la actividad. (El modelo de registro debe conocer el estado del modelo para que pueda realizar la validación).
  4. Agregue este evento a la secuencia de eventos no confirmados almacenados en el modelo de registro de actividad.
  5. Guarde la secuencia de eventos no confirmados en el repositorio.
  6. Genere el evento LessonWasBooked a nivel mundial para informar a todos los proyectores de lectura para actualizar la tabla de lecciones.

Primero necesita crear un modelo de grabación para la lección. Usaremos el método estático de fábrica Lesson::bookClientOntoNewLesson() . Genera un nuevo evento LessonWasOpened (la lección está abierta), aplica este evento a sí mismo (solo establece su ID), agrega el nuevo evento a la lista de eventos no confirmados en forma de DomainEventMessage (el evento más algunos metadatos que usamos al guardar en el almacén de eventos).

El proceso se repite para agregar un cliente al evento. Al aplicar el evento ClientWasBookedOntoLesson (el cliente se inscribió en la lección), el modelo de grabación no rastrea los nombres de los clientes, sino solo el número de clientes registrados. Los modelos de registro no necesitan conocer los nombres de los clientes para garantizar la coherencia.

Los métodos applyLessonWasOpened y applyClientWasBookedOntoLesson pueden parecer un poco extraños en applyClientWasBookedOntoLesson momento. Los utilizaremos más adelante cuando necesitemos reproducir eventos antiguos para formar el estado del modelo de grabación. No es fácil de explicar, así que le daré un código que lo ayudará a comprender este proceso. Más adelante extraeremos el código que procesa eventos no comprometidos y genera mensajes de eventos de dominio.

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

Podemos extraer los componentes CQRS de nuestro modelo de grabación: fragmentos de la clase involucrados en el procesamiento de eventos no confirmados. También podemos borrar la API para una entidad generada por eventos creando una función segura apply() que acepte el evento, llame al método applyEventName() correspondiente y agregue un nuevo evento DomainEventMessage a la lista de eventos no confirmados. La clase extraída es un detalle de la implementación de CQRS y no contiene lógica de dominio, por lo que podemos crear un nuevo espacio de nombres: App \ CQRS:

Presta atención al código de la app/CQRS/EventSourcedEntity.php
Para que el código funcione, necesitamos agregar la clase DomainEventMessage , que es un DTO simple; se puede encontrar en app/CQRS/DomainEventMessage.php

Por lo tanto, obtuvimos un sistema que genera eventos para cada intento de escritura y utiliza eventos para registrar los cambios necesarios para evitar invariantes. El siguiente paso es guardar estos eventos en la tienda (EventStore). En primer lugar, es necesario crear este repositorio de eventos. Para simplificar, usaremos el modelo Eloquent, una tabla simple de SQL con los siguientes campos: * UUID (para saber a qué entidad aplicar el evento) * event_payload (mensaje serializado que contiene todo lo necesario para recrear el evento) * event_payload - timestamp para saber cuándo el evento sucedió Si revisa cuidadosamente el código, verá que creé dos comandos: para crear y destruir nuestra tabla de almacenamiento de eventos:

  • php artisan eloquenteventstore: create (App \ CQRS \ EloquentEventStore \ CreateEloquentEventStore)
  • php artisan eloquenteventstore: drop (App \ CQRS \ EloquentEventStore \ DropEloquentEventStore) (no olvide agregarlos a App \ Console \ Kernel.php para que se carguen).

Hay dos muy buenas razones para no usar SQL como un almacén de eventos: no implementa el modelo de solo agregado (solo agrega datos, los eventos deben ser inmutables) y también porque SQL no es un lenguaje de consulta ideal para bases de datos temporales. Programamos la interfaz para facilitar el reemplazo de la tienda de eventos en publicaciones posteriores.

Para guardar eventos, use el repositorio. Cada vez que se llama save() para el modelo de grabación, guardamos la lista de eventos no comprometidos en el almacén de eventos. Para almacenar eventos, necesitamos un mecanismo para su serialización y deserialización. Crea un serializador para esto. Necesitamos metadatos, como una clase de evento (por ejemplo, App\School\Lesson\Events\LessonWasOpened ) y una carga útil del evento (datos necesarios para reconstruir un evento).

Todo esto se codificará en formato JSON y luego se escribirá en nuestra base de datos junto con el UUID de la entidad y la marca de tiempo. Queremos actualizar nuestros modelos de lectura después de capturar eventos, por lo que el repositorio activará cada evento después de guardar. El serializador será responsable de escribir la clase de evento, mientras que el evento será responsable de serializar su carga útil. Un evento completamente serializado se verá así:

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

Dado que todos los eventos requieren un método de serialización y deserialización, podemos crear una interfaz SerializableEvent y agregar una indicación del tipo de valor esperado. Actualice nuestro evento LessonWasOpened :

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

Crear un repositorio de LessonRepository . Podemos refactorizar y extraer los componentes centrales de CQRS más adelante.

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 ejecuta la prueba de integración nuevamente y luego verifica la tabla SQL domain_events , debería ver dos eventos en la base de datos.

Nuestro paso final para pasar la prueba con éxito es escuchar los eventos de transmisión y actualizar la proyección del modelo de lectura de la Lección. Los eventos de transmisión de la lección serán interceptados por el LessonProjector , que aplicará los cambios necesarios a LessonProjection (modelos LessonProjection de la tabla de la lección):

  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 ejecuta la prueba, verá que se ha producido un error de SQL:

 Unknown column 'clientName' in 'field list' 

Tan pronto como creamos una migración para agregar clientName a la tabla de lecciones, pasaremos la prueba con éxito. Hemos implementado la funcionalidad básica de CQRS: los equipos crean eventos que se utilizan para generar modelos de lectura.

Mejorando el modelo de lectura con enlaces


¡Hemos alcanzado un hito significativo, pero eso no es todo! Hasta ahora, el modelo de lectura solo admite un cliente (especificamos tres en nuestras reglas de dominio). Los cambios que hacemos en el modelo de lectura son bastante simples: solo creamos un modelo de proyección de Cliente y un ClientProjector que ClientBookedOntoLesson evento ClientBookedOntoLesson . Primero, actualizamos nuestra prueba para reflejar los cambios que queremos ver en nuestro modelo de lectura:

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

Esta es una demostración clara de la facilidad con que puede cambiar los modelos de lectura. Todo, hasta el repositorio de eventos, permanece sin cambios. Como beneficio adicional, cuando usamos el sistema de eventos, obtenemos datos para pruebas básicas: al cambiar el proyector del modelo de lectura, escuchamos cada evento que alguna vez ha sucedido en nuestro sistema.

Reproducimos estos eventos usando el nuevo proyector, verificamos las excepciones y comparamos los resultados con proyecciones anteriores. Después de que el sistema haya estado funcionando durante algún tiempo, tendremos una selección bastante representativa de eventos para probar nuestros proyectores.

Nuestro modelo de grabación actualmente no tiene la capacidad de cargar el estado actual. Si queremos agregar un segundo cliente a la lección, simplemente podemos crear un segundo evento ClientWasAddedToLesson, pero no podemos proporcionar protección contra los invariantes. Para mayor claridad, propongo escribir una segunda prueba simulando la grabación de dos clientes por lección.

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

Para nuestro modelo de grabación, necesitamos implementar un método para "cargar" una entidad para la cual los eventos ya se aplican en la tienda de eventos. Podemos lograr esto reproduciendo cada evento que se refiere al UUID de la entidad. En términos generales, el proceso es el siguiente:

  1. Recibimos todos los mensajes de eventos relevantes de la tienda de eventos.
  2. Para cada mensaje, recreamos el evento correspondiente.
  3. Creamos un nuevo modelo de registro de entidad y reproducimos cada evento.

Por el momento, nuestras pruebas arrojan excepciones, por lo que comenzaremos creando el BookClientOntoLesson necesario (registre un cliente para la lección), utilizando el comando BookLesson como plantilla. El método del controlador se verá así:

  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 función de carga del repositorio devuelve una matriz de eventos recreados. Para hacer esto, primero encuentra mensajes sobre eventos en el repositorio y luego los pasa al Serializer para convertir cada mensaje en un evento. Serializer crea mensajes a partir de eventos, por lo que debemos agregar el método deserialize() para realizar la transformación inversa. Recuerde que el Serializer se pasa a cada evento para serializar los datos del evento (por ejemplo, el nombre del cliente). Haremos lo mismo para realizar la transformación inversa, mientras que nuestra interfaz SerializableEvent debe actualizarse utilizando el método deserialize() . Miremos el código para que todo encaje en su lugar. Primero está la EventStoreRepository carga 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; } 

Usando la función de deserialización apropiada en 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 conclusión, utilizaremos el método de fábrica estático deserialize() en LessonWasOpened (necesitamos agregar este método a cada evento)

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

Ahora tenemos una matriz de todos los eventos que acabamos de reproducir en relación con nuestro modelo de registro de entidad para inicializar el estado en el método initializeState en app/CQRS/EventSouredEntity.php

Ahora ejecuta nuestra prueba. Bingo!
De hecho, por el momento no tenemos una prueba para verificar el cumplimiento de nuestras reglas de dominio, así que escribámoslo:

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

Tenga en cuenta que solo necesitamos lessonId : esta prueba reinicializa el estado de la lección durante cada comando.

Por el momento, simplemente transferimos UUID creados manualmente, mientras que en realidad queremos generarlos automáticamente. Voy a usar el paquete Ramsy\UUID , así que vamos a instalarlo con el composer :

 $> composer require ramsey/uuid 

Ahora actualice nuestras pruebas para usar el nuevo paquete:

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

Ahora el nuevo desarrollador del proyecto puede mirar el código, ver App\School\ReadModels , que contiene un conjunto de modelos Eloquent, y usar estos modelos para escribir cambios en la tabla de lecciones. Podemos evitar esto creando una clase ImmutableModel que amplíe la clase Eloquent Model y anule el método de guardar en app/CQRS/ReadModelImmutableModel.php .

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


All Articles