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:
- Validação: comandos imperativos podem falhar e os eventos já ocorreram e, portanto, não devem falhar.
- Crie um novo evento LessonWasBooked (inscrito em uma lição).
- Atualize o status da atividade. (O modelo de registro deve estar ciente do estado do modelo para poder executar a validação.)
- Inclua este evento no fluxo de eventos não confirmados armazenados no modelo de registro de atividade.
- Salve o fluxo de eventos não confirmados no repositório.
- 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:
- Recebemos todas as mensagens de eventos relevantes do armazenamento de eventos.
- Para cada mensagem, recriamos o evento correspondente.
- 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
.