Geração de Eventos, CQRS e Laravel

A tradução do artigo foi preparada para os alunos do curso profissional "Framework Laravel"





1. Introdução


Este artigo é dedicado aos conceitos básicos da criação de sistemas CQRS de eventos na linguagem PHP e na estrutura do Laravel. Supõe-se que você esteja familiarizado com o esquema de desenvolvimento usando o barramento de comando e tenha uma idéia de eventos (em particular, a publicação de eventos para uma matriz de ouvintes). Para atualizar esse conhecimento, você pode usar o serviço Laracasts. Além disso, supõe-se que você tenha um certo entendimento do princípio do CQRS. Caso contrário, recomendo ouvir duas palestras: Workshop de Mathias Verraes sobre Geração de Eventos e CQRS e Geração de Eventos de Greg Young .

Não use o código fornecido aqui em seus projetos! É uma plataforma de aprendizado para entender as idéias por trás do CQRS. Esse código não pode ser considerado confiável, é mal testado e, além disso, raramente programa interfaces, portanto será muito mais difícil alterar partes individuais do código. Um exemplo muito melhor de um pacote CQRS que você pode usar é o Broadway, desenvolvido pelo Qandidate Lab . Este é um código limpo e pouco acoplado, no entanto, algumas abstrações não tornam totalmente claro se você nunca encontrou sistemas de eventos.

E o último - meu código está conectado aos eventos e ao barramento de comando do Laravel. Eu queria ver como o código ficaria no Laravel (eu costumo usar essa estrutura para projetos de pequenas agências); no entanto, olhando para trás, acho que devo criar minhas próprias implementações. Espero que meu código seja claro, mesmo para aqueles que não usam frameworks.

No github, o código está localizado em https://github.com/scazz/cqrs-tutorial.git e, em nosso guia, consideraremos seus componentes para aumentar a lógica.

Criaremos um sistema de registro inicial para uma escola de surf. Com sua ajuda, os clientes da escola podem se inscrever para as aulas. Para o processo de gravação, formulamos as seguintes regras:

  • Cada lição deve ter pelo menos um cliente ...
  • ... mas não mais que três.

Um dos recursos mais impressionantes dos sistemas CQRS baseados em eventos é a criação de modelos de leitura específicos para cada métrica necessária ao sistema. Você encontrará exemplos de projeção de modelos de leitura no ElasticSearch, e Greg Young implementou uma linguagem orientada a assuntos em seu armazenamento de eventos para lidar com eventos complexos. No entanto, para simplificar, nossa projeção de leitura será um banco de dados SQL padrão para uso com o Eloquent. Como resultado, teremos uma tabela para classes e outra para clientes.

O conceito de geração de eventos também permite processar eventos offline. Mas neste artigo, aderirei ao máximo aos modelos de desenvolvimento “tradicionais” (novamente por simplicidade), e nossas projeções de leitura serão atualizadas em tempo real, imediatamente após os eventos serem armazenados na loja.

Configuração do projeto e primeiro teste


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

Crie um novo projeto Laravel 5

 $> laravel new cqrs-tutorial 

E, para começar, precisamos de um teste. Usaremos o teste de integração, que garantirá que o registro do cliente para as aulas leve ao fato de que a lição é criada em nosso modelo Eloquent.

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

Pré-atribuímos a nova atividade a um ID, criamos uma equipe para se inscrever em uma nova atividade e pedimos ao Laravel para enviá-la. Na tabela de lições, precisamos criar um novo registro que possamos ler usando o modelo Eloquent. Como precisamos de um banco de dados, preencha seu arquivo .env corretamente.

Cada evento gravado em nosso armazenamento de eventos é anexado à raiz do agregado, que chamaremos simplesmente de entidade (Entidade) - uma abstração para fins educacionais apenas adiciona confusão. O ID é um identificador exclusivo universal (UUID). O armazenamento de eventos não se importa se o evento se aplica à lição ou ao cliente. Ele só sabe que está associado a um ID.

Com base nos erros identificados durante o teste, podemos criar classes ausentes. Primeiro, criaremos a classe LessonId, depois o comando BookLesson (não se preocupe com o método manipulador ainda, apenas continue executando o teste). A classe Lesson é um modelo de leitura fora do espaço de nomes da lição. Um modelo de leitura exclusivo - a lógica da área de assunto nunca será armazenada aqui. Em conclusão, precisaremos criar uma migração para a tabela de lições.

Para manter a clareza do código, usarei a biblioteca de verificação de asserções. Pode ser adicionado com o seguinte comando:

 $> composer require beberlei/assert 

Considere o processo que deve ser iniciado por este comando:

  1. Validação: comandos imperativos podem falhar e os eventos já ocorreram e, portanto, não devem falhar.
  2. Crie um novo evento LessonWasBooked (inscrito em uma lição).
  3. Atualize o status da atividade. (O modelo de registro deve estar ciente do estado do modelo para poder executar a validação.)
  4. Inclua este evento no fluxo de eventos não confirmados armazenados no modelo de registro de atividade.
  5. Salve o fluxo de eventos não confirmados no repositório.
  6. Aumente o evento LessonWasBooked globalmente para informar todos os projetores de leitura para atualizar a tabela de lições.

Primeiro, você precisa criar um modelo de gravação para a lição. Usaremos o método de fábrica estático Lesson::bookClientOntoNewLesson() . Ele gera um novo evento LessonWasOpened (a lição está aberta), aplica esse evento a si mesmo (apenas define seu ID), adiciona o novo evento à lista de eventos não confirmados na forma de DomainEventMessage (o evento mais alguns metadados que usamos ao salvar no armazenamento de eventos).

O processo é repetido para adicionar um cliente ao evento. Ao aplicar o evento ClientWasBookedOntoLesson (o cliente foi inscrito na lição), o modelo de gravação não rastreia os nomes dos clientes, mas apenas o número de clientes registrados. Os modelos de registro não precisam saber os nomes dos clientes para garantir consistência.

Os métodos applyClientWasBookedOntoLesson e applyClientWasBookedOntoLesson podem parecer um pouco estranhos applyClientWasBookedOntoLesson momento. Nós os usaremos mais tarde quando precisarmos reproduzir eventos antigos para formar o estado do modelo de gravação. Não é fácil de explicar, por isso darei um código que ajudará você a entender esse processo. Mais tarde, extrairemos o código que processa eventos não confirmados e gera mensagens de eventos do domínio.

 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 extrair os componentes do CQRS do nosso modelo de gravação - fragmentos da classe envolvidos no processamento de eventos não confirmados. Também podemos limpar a API de uma entidade gerada por evento, criando uma função apply() segura que aceita o evento, chama o método applyEventName() correspondente e adiciona um novo evento DomainEventMessage à lista de eventos não confirmados. A classe extraída é um detalhe da implementação do CQRS e não contém lógica de domínio; portanto, podemos criar um novo espaço para nome: App \ CQRS:

Preste atenção ao código app/CQRS/EventSourcedEntity.php
Para que o código funcione, precisamos adicionar a classe DomainEventMessage , que é um DTO simples - ele pode ser encontrado em app/CQRS/DomainEventMessage.php

Assim, obtivemos um sistema que gera eventos para cada tentativa de gravação e usa eventos para registrar as alterações necessárias para evitar invariantes. O próximo passo é salvar esses eventos na loja (EventStore). Primeiro de tudo, esse repositório de eventos precisa ser criado. Para simplificar, usaremos o modelo Eloquent, uma tabela SQL simples com os seguintes campos: * UUID (para saber a qual entidade aplicar o evento) * event_payload (mensagem serializada contendo tudo o necessário para recriar o evento) * recordedAt - timestamp para saber quando o evento aconteceu. Se você revisar cuidadosamente o código, verá que eu criei dois comandos - para criar e destruir nossa tabela de armazenamento de eventos:

  • php artisan eloquenteventstore: create (App \ CQRS \ EloquentEventStore \ CreateEloquentEventStore)
  • php artisan eloquenteventstore: drop (App \ CQRS \ EloquentEventStore \ DropEloquentEventStore) (não esqueça de adicioná-los ao App \ Console \ Kernel.php para que eles carreguem).

Há dois motivos muito bons para não usar o SQL como um armazenamento de eventos: ele não implementa o modelo somente de acréscimo (apenas a adição de dados, os eventos devem ser imutáveis) e também porque o SQL não é uma linguagem de consulta ideal para bancos de dados temporais. Programamos a interface para facilitar a substituição do armazenamento de eventos nas publicações subsequentes.

Para salvar eventos, use o repositório. Sempre que save() é chamado para o modelo de gravação, salvamos a lista de eventos não confirmados no armazenamento de eventos. Para armazenar eventos, precisamos de um mecanismo para serialização e desserialização. Crie um serializador para isso. Precisamos de metadados, como uma classe de evento (por exemplo, App\School\Lesson\Events\LessonWasOpened ) e uma carga útil de evento (dados necessários para reconstruir um evento).

Tudo isso será codificado no formato JSON e, em seguida, gravado em nosso banco de dados junto com a entidade UUID e o registro de data e hora. Queremos atualizar nossos modelos de leitura após a captura de eventos, para que o repositório acione todos os eventos após salvar. O serializador será responsável por escrever a classe do evento, enquanto o evento será responsável por serializar sua carga útil. Um evento totalmente serializado será mais ou menos assim:

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

Como todos os eventos exigem um método de serialização e desserialização, podemos criar uma interface SerializableEvent e adicionar uma indicação do tipo de valor esperado. Atualize nosso evento LessonWasOpened :

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

Crie um repositório LessonRepository . Podemos refatorar e extrair os componentes principais do CQRS posteriormente.

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

Se você executar o teste de integração novamente e depois verificar a tabela SQL domain_events , deverá ver dois eventos no banco de dados.

Nossa etapa final para passar com êxito no teste é ouvir os eventos de transmissão e atualizar a projeção do modelo de leitura da lição. Os eventos de transmissão da lição serão interceptados pelo LessonProjector , que aplicará as alterações necessárias no LessonProjection (modelos eloquentes da tabela de lições):

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

Se você executar o teste, verá que ocorreu um erro SQL:

 Unknown column 'clientName' in 'field list' 

Assim que criarmos uma migração para adicionar clientName à tabela de lições, passaremos no teste com êxito. Implementamos a funcionalidade básica do CQRS: as equipes criam eventos que são usados ​​para gerar modelos de leitura.

Melhorando o modelo de leitura com links


Atingimos um marco significativo, mas isso não é tudo! Até agora, o modelo de leitura suporta apenas um cliente (nós especificamos três em nossas regras de domínio). As alterações que fazemos no modelo de leitura são bastante simples: apenas criamos um modelo de projeção do cliente e um ClientProjector que captura o evento ClientBookedOntoLesson . Primeiro, atualizamos nosso teste para refletir as alterações que queremos ver em nosso modelo de leitura:

 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 é uma demonstração clara de como é fácil alterar os modelos de leitura. Tudo, até o repositório de eventos, permanece inalterado. Como bônus, ao usar o sistema de eventos, obtemos dados para testes básicos - ao trocar o projetor do modelo de leitura, ouvimos todos os eventos que já aconteceram em nosso sistema.

Reproduzimos esses eventos usando o novo projetor, verificamos exceções e comparamos os resultados com as projeções anteriores. Depois que o sistema estiver funcionando há algum tempo, teremos uma seleção bastante representativa de eventos para testar nossos projetores.

Atualmente, nosso modelo de gravação não tem capacidade para carregar o estado atual. Se quisermos adicionar um segundo cliente à lição, podemos simplesmente criar um segundo evento ClientWasAddedToLesson, mas não podemos fornecer proteção contra invariantes. Para maior clareza, proponho escrever um segundo teste simulando a gravação de dois clientes por aula.

 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 o nosso modelo de gravação, precisamos implementar um método de "carregar" uma entidade para a qual os eventos já se aplicam a ela no armazenamento de eventos. Podemos conseguir isso reproduzindo todos os eventos que se referem ao UUID da entidade. Em termos gerais, o processo é o seguinte:

  1. Recebemos todas as mensagens de eventos relevantes do armazenamento de eventos.
  2. Para cada mensagem, recriamos o evento correspondente.
  3. Criamos um novo modelo de registro de entidade e reproduzimos cada evento.

No momento, nossos testes lançam exceções, portanto, começaremos criando o BookClientOntoLesson necessário (registrar um cliente para a lição), usando o comando BookLesson como modelo. O método manipulador terá a seguinte aparência:

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

A função de carregamento do repositório retorna uma matriz de eventos recriados. Para fazer isso, ela primeiro encontra mensagens sobre eventos no repositório e depois as transmite ao Serializer para converter cada mensagem em um evento. Serializer cria mensagens a partir de eventos, portanto, precisamos adicionar o método deserialize() para executar a transformação inversa. Lembre-se de que o Serializer é passado para cada evento para serializar os dados do evento (por exemplo, o nome do cliente). Faremos o mesmo para executar a transformação inversa, enquanto nossa interface SerializableEvent deve ser atualizada usando o método deserialize() . Vejamos o código para que tudo se encaixe. A primeira é a EventStoreRepository carregamento 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 a função de desserialização apropriada no 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); } 

Em conclusão, usaremos o método estático de fábrica deserialize() em LessonWasOpened (precisamos adicionar esse 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); } 

Agora temos uma matriz de todos os eventos que acabamos de reproduzir em relação ao nosso modelo de registro de entidade para inicializar o estado no método initializeState em app/CQRS/EventSouredEntity.php

Agora execute nosso teste. Bingo!
De fato, no momento não temos um teste para verificar a conformidade com nossas regras de domínio, então vamos escrever:

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

Observe que precisamos apenas de lessonId - esse teste reinicializa o estado da lição durante cada comando.

No momento, simplesmente transferimos UUID criados manualmente, enquanto na realidade queremos gerá-los automaticamente. Vou usar o pacote Ramsy\UUID , então vamos instalá-lo com o composer :

 $> composer require ramsey/uuid 

Agora atualize nossos testes para usar o novo pacote:

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

Agora, o novo desenvolvedor de projeto pode examinar o código, consulte App\School\ReadModels , que contém um conjunto de modelos Eloquent, e usar esses modelos para gravar alterações na tabela de lições. Podemos evitar isso criando uma classe ImmutableModel que estende a classe Eloquent Model e substitui o método save em app/CQRS/ReadModelImmutableModel.php .

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


All Articles