本文的翻译是为专业课程“ Framework Laravel”的学生准备的

引言
本文致力于以PHP语言和Laravel框架创建事件CQRS系统的基础知识。 假定您熟悉使用命令总线的开发方案并且对事件有所了解(尤其是针对一系列侦听器的事件发布)。 要刷新此知识,可以使用Laracasts服务。 另外,假设您对CQRS原理有一定的了解。 如果没有,我强烈建议您听两个讲座:
Mathias Verraes关于事件生成的研讨会和
Greg Young的CQRS和Event Generation 。
不要在您的项目中使用此处给出的代码! 这是一个了解CQRS背后思想的学习平台。 此代码不能被称为可靠的,未经测试,而且我很少编程接口,因此更改代码的各个部分将变得更加困难。 可以使用的CQRS软件包更好的例子是
Qandidate Lab开发的Broadway 。 这是干净的,松散耦合的代码,但是,如果您从未遇到过事件系统,则有些抽象无法完全弄清。
最后一个-我的代码与事件和Laravel命令总线有关。 我想看看代码在Laravel中的外观(我通常将这个框架用于小型机构的项目),但是回想一下,我认为我应该创建自己的实现。 我希望即使对于那些不使用框架的人,我的代码也将是清晰的。
在github上,代码位于
https://github.com/scazz/cqrs-tutorial.git ,在我们的指南中,我们将考虑其组件以增加逻辑。
我们将为冲浪学校创建一个初始注册系统。 在它的帮助下,学校客户可以注册课程。 对于记录过程,我们制定以下规则:
- 每节课必须至少有一个客户...
- ...但不超过三个。
基于事件的CQRS系统最令人印象深刻的功能之一是针对系统所需的每个指标创建读取模型。 您将在ElasticSearch中找到阅读模型的投影示例,而Greg Young在其事件存储中实现了一种面向主题的语言,用于处理复杂事件。 但是,为简单起见,我们的读取投影将是与Eloquent一起使用的标准SQL数据库。 结果,我们将有一个表用于类,一个表用于客户。
事件生成的概念还允许您脱机处理事件。 但是在本文中,我将最大程度地坚持“传统”开发模型(再次简化),并且将事件存储在存储库中后,我们的阅读预测将实时更新。
项目设置和首次测试
git clone https://github.com/scazz/cqrs-tutorial
创建一个新的Laravel 5项目
$> laravel new cqrs-tutorial
对于初学者,我们需要测试。 我们将使用集成测试,这将确保客户的课程注册会导致在Eloquent模型中创建课程。
列出测试/ 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 ); } }
我们将新活动预先分配给一个ID,创建一个团队来注册新活动,然后告诉Laravel发送它。 在课程表中,我们需要创建一条新记录,我们可以使用Eloquent模型读取该记录。 我们需要一个数据库,因此请正确填写您的.env文件。
在事件存储中记录的每个事件都附加到聚合的根,我们将其简称为实体(Entity)-用于教育目的的抽象只会增加混乱。 ID是通用唯一标识符(UUID)。 活动存储区并不关心活动是否适用于课程或客户。 他只知道它与ID相关联。
根据测试期间发现的错误,我们可以创建缺少的类。 首先,我们将创建LessonId类,然后创建BookLesson命令(现在不用担心处理程序方法,只需继续运行测试即可)。 Lesson类是Lesson名称空间之外的阅读模型。 独家阅读模型-主题区域的逻辑永远不会存储在这里。 总之,我们将需要为课程表创建迁移。
为了保持代码的清晰度,我将使用断言验证库。 可以使用以下命令添加它:
$> composer require beberlei/assert
考虑应该由该命令启动的过程:
- 验证:命令性命令可能会失败,并且事件已经发生,因此不应失败。
- 创建一个新的LessonWasBooked事件(注册一个课程)。
- 更新活动状态。 (记录模型必须知道模型的状态,以便它可以执行验证。)
- 将此事件添加到活动记录模型中存储的未提交事件流中。
- 将未提交的事件流保存到存储库。
- 在全局范围内引发LessonWasBooked事件,以通知所有正在阅读的投影仪更新课程表。
首先,您需要为该课程创建一个录音模型。 我们将使用静态工厂方法
Lesson::bookClientOntoNewLesson()
。 它会生成一个新的
LessonWasOpened
事件(课程已打开),将其应用于自身(仅设置其ID),然后将新事件以
DomainEventMessage
的形式添加到未提交的事件列表中(事件以及一些保存到事件存储时使用的元数据)。
重复该过程以将客户端添加到事件中。 当应用
ClientWasBookedOntoLesson
事件(客户端已注册课程)时,记录模型不会跟踪客户端名称,而只会跟踪已注册客户端的数量。 记录模型不需要知道客户名称即可确保一致性。
现在,
applyLessonWasOpened
和
applyClientWasBookedOntoLesson
方法可能看起来有些奇怪。 当我们需要重现旧事件以形成记录模型的状态时,将在以后使用它们。 解释起来并不容易,因此,我将提供一个代码,以帮助您了解此过程。 稍后,我们将提取处理uncommittedEvents并生成域事件消息的代码。
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++; }
我们可以从记录模型中提取CQRS组件-涉及处理未提交事件的类的片段。 我们还可以通过创建一个安全的
apply()
函数来清除事件生成的实体的API,该函数接受事件,调用相应的
applyEventName()
方法,并将新的
DomainEventMessage
事件添加到未提交的事件列表中。 提取的类是CQRS实现的详细信息,并且不包含域逻辑,因此我们可以创建一个新的命名空间:App \ CQRS:
注意代码app/CQRS/EventSourcedEntity.php
为了使代码正常工作,我们需要添加DomainEventMessage
类,这是一个简单的DomainEventMessage
可以在app/CQRS/DomainEventMessage.php
因此,我们得到了一个系统,该系统为每次写尝试生成事件,并使用事件记录必要的更改以防止不变。 下一步是将这些事件保存在商店(EventStore)中。 首先,需要创建此事件存储库。 为简化起见,我们将使用Eloquent模型,它是一个带有以下字段的简单SQL表:*
UUID
(用于了解将事件应用到哪个实体)*
event_payload
(包含重新创建事件所需的所有内容的序列化消息)*
event_payload
用于了解事件发生时间的时间戳发生了 如果仔细查看代码,您将看到我创建了两个命令-创建和销毁事件存储表:
- php artisan eloquenteventstore:创建(应用程序\ CQRS \ EloquentEventStore \ CreateEloquentEventStore)
- php artisan eloquenteventstore:删除(App \ CQRS \ EloquentEventStore \ DropEloquentEventStore)(别忘了将它们添加到App \ Console \ Kernel.php以便它们加载)。
不使用SQL作为事件存储有两个很好的理由:它没有实现仅追加模型(仅添加数据,事件必须是不可变的),也因为SQL并不是时态数据库的理想查询语言。 我们对接口进行编程,以方便在后续出版物中替换事件存储。
要保存事件,请使用存储库。 每当为记录模型调用
save()
,我们都会将uncommittedEvents列表保存在事件存储中。 为了存储事件,我们需要一种对其进行序列化和反序列化的机制。 为此创建一个序列化器。 我们将需要元数据,例如事件类(例如
App\School\Lesson\Events\LessonWasOpened
)和事件有效负载(重建事件所需的数据)。
所有这些将以JSON格式编码,然后与实体UUID和时间戳一起写入我们的数据库。 我们想在捕获事件后更新阅读模型,因此存储库将在保存后触发每个事件。 序列化程序将负责编写事件类,而事件将负责序列化其有效负载。 完全序列化的事件如下所示:
{ class: "App\\School\\Lesson\\Events\\", event: $event->serialize() }
由于所有事件都需要序列化和反序列化方法,因此我们可以创建一个
SerializableEvent
接口并添加期望值类型的指示。 更新我们的
LessonWasOpened
事件:
app/School/Lesson/Events/LessonWasOpened.php class LessonWasOpened implements SerializableEvent { public function serialize() { return array( 'lessonId'=> (string) $this->getLessonId() ); } }
创建一个
LessonRepository
存储库。 我们稍后可以重构和提取核心CQRS组件。
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()); } } }
如果再次运行集成测试,然后检查
domain_events
SQL表,则应该在数据库中看到两个事件。
成功通过测试的最后一步是收听广播事件并更新Lesson阅读模型的预测。 Lesson广播事件将由
LessonProjector
拦截,该事件将对
LessonProjection
(课程表的
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"; }
如果运行测试,您将看到发生了SQL错误:
Unknown column 'clientName' in 'field list'
一旦创建迁移以将
clientName
添加到课程表中,我们就将成功通过测试。 我们已经实现了CQRS的基本功能:团队创建用于生成阅读模型的事件。
通过链接改善阅读模式
我们已经达到了一个重要的里程碑,但这还不是全部! 到目前为止,阅读模型仅支持一个客户端(我们在域规则中指定了三个)。 我们对阅读模型的更改非常简单:我们只创建一个Client投影模型和一个
ClientProjector
来捕获
ClientBookedOntoLesson
事件。 首先,我们更新测试以反映我们希望在阅读模型中看到的变化:
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); }
这清楚地说明了更改阅读模型有多么容易。 直到事件存储库的所有内容都保持不变。 另外,使用事件系统时,我们会获得用于基本测试的数据-更改阅读模型的投影仪时,我们会聆听系统中发生过的所有事件。
我们使用新的投影仪再现这些事件,检查异常情况,并将结果与以前的投影进行比较。 系统运行一段时间后,我们将有相当具代表性的事件选择来测试我们的投影机。
我们的记录模型当前不具备加载当前状态的能力。 如果要在课程中添加第二个客户端,我们可以简单地创建第二个ClientWa⚓ToLesson事件,但是我们不能提供针对不变性的保护。 为了更加清晰,我建议编写第二个测试来模拟每节课中两个客户端的录制。
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); }
对于我们的记录模型,我们需要实现一种“加载”已在事件存储中对其应用了事件的实体的方法。 我们可以通过回放引用实体UUID的每个事件来实现此目的。 一般而言,该过程如下:
- 我们从事件存储接收所有相关的事件消息。
- 对于每条消息,我们都会重新创建相应的事件。
- 我们创建一个新的实体记录模型并播放每个事件。
目前,我们的测试会引发异常,因此我们将从使用
BookLesson
命令作为模板创建必要的
BookClientOntoLesson
(为课程注册客户端)开始。 该处理程序方法将如下所示:
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; }
存储库加载功能返回一个重新创建的事件数组。 为此,她首先在存储库中找到有关事件的消息,然后将它们传递给
Serializer
以将每个消息转换为事件。
Serializer
从事件创建消息,因此我们需要添加
deserialize()
方法以执行逆变换。 回想一下,将
Serializer
器传递给每个事件以序列化事件数据(例如,客户端名称)。 我们将执行相同的操作以执行逆变换,而我们的
SerializableEvent
接口必须使用
deserialize()
方法进行更新。 让我们看一下代码,以便一切都准备就绪。 首先是
EventStoreRepository
加载
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; }
在
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); }
总之,我们将在
LessonWasOpened
使用静态工厂方法
LessonWasOpened
(
deserialize()
(我们需要将此方法添加到每个事件中)
app/School/Lesson/Events/LessonWasOpened.php public static function deserialize($data) { $lessonId = new LessonId($data->lessonId); return new self($lessonId); }
现在,相对于我们的实体记录模型,我们有了一个刚刚再现的所有事件的数组,用于初始化
app/CQRS/EventSouredEntity.php
中的
initializeState
方法中的状态
现在运行我们的测试。 宾果!
实际上,目前我们还没有测试来验证是否符合我们的域规则,因此我们来编写它:
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") ); }
请注意,我们只需要
lessonId
此测试将在每个命令期间重新初始化课程的状态。
目前,我们仅传输手动创建的
UUID
,而实际上我们希望自动生成它们。 我将使用
Ramsy\UUID
软件包,因此让我们与
composer
一起安装它:
$> composer require ramsey/uuid
现在更新测试以使用新软件包:
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) ); }
现在,新项目开发人员可以查看代码,请参阅
App\School\ReadModels
,其中包含一组
App\School\ReadModels
模型,并使用这些模型将更改写入课程表。 我们可以通过创建一个
ImmutableModel
类来防止这种情况,该类可以扩展Eloquent Model类并覆盖
app/CQRS/ReadModelImmutableModel.php
的save方法。