事件源是一个在过去几年中在PHP社区越来越流行的术语,但它对许多开发者来说仍然是一个谜。问题总是怎么做和为什么做,这也是可以理解的。本教程旨在帮助你不仅了解什么是事件源,以一种实用的方式,而且知道什么时候你可能要使用它。
在传统的应用程序中,我们的应用程序状态直接体现在我们所连接的数据库中。我们并不完全了解它是如何到达那里的。我们所知道的就是它的存在。有一些方法可以让我们更多地了解这一点,使用审计模型变化的工具,这样我们就可以看到什么被改变了,由谁改变的。这也是朝着正确方向迈出的一步。然而,我们仍然不了解关键问题。
为什么?为什么这个模式会改变?这种改变的目的是什么?
这就是事件源的优势所在,通过保持应用状态发生的历史视图,以及它为什么发生变化。事件源允许你在过去的基础上做出决定,使你能够生成报告。但在其基本层面上,它让你知道为什么应用程序的状态会改变。这是通过事件完成的。
我将建立一个基本的Laravel项目来指导你如何工作。我们要做的应用相对简单,这样你就可以理解事件源逻辑,而不是迷失在应用逻辑中。我们正在建立一个应用程序,我们可以庆祝团队成员。就是这样。简单而容易理解。我们有用户的团队, 我们希望能够在团队中公开庆祝一些事情。
我们将从一个新的Laravel项目开始,但我将使用Jetstream,因为我想引导认证和团队结构和功能。一旦你建立了这个项目, 在你选择的IDE中打开它(当然正确的答案是PHPStorm), 我们就可以在Laravel中深入研究一些事件源。
我们要为我们的应用程序创建一个额外的模型, 这是我们唯一的一个。这将是一个Celebration ,你可以使用下面的artisan命令来创建这个模型。
php artisan make:model Celebration -m
修改你的migrations up方法,看起来像下面这样。
public function up(): void
{
Schema::create('celebrations', static function (Blueprint $table): void {
$table->id();
$table->string('reason');
$table->text('message')->nullable();
$table
->foreignId('user_id')
->index()
->constrained()
->cascadeOnDelete();
$table
->foreignId('sender_id')
->index()
->constrained('users')
->cascadeOnDelete();
$table
->foreignId('team_id')
->index()
->constrained()
->cascadeOnDelete();
$table->timestamps();
});
}
我们有一个庆祝活动reason ,一个简单的句子,然后是一个可选的message ,我们可能想和庆祝活动一起送过去。同时,我们有三个关系,被庆祝的用户,发送庆祝信息的用户,以及他们所在的团队。在Jetstream中,一个用户可以属于多个团队,可能会出现两个用户都在同一个团队的情况,我们要确保在正确的团队中公开庆祝他们。
一旦我们有了这个设置,让我们看一下模型本身。
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Celebration extends Model
{
use HasFactory;
protected $fillable = [
'reason',
'message',
'user_id',
'sender_id',
'team_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(
related: User::class,
foreignKey: 'user_id',
);
}
public function sender(): BelongsTo
{
return $this->belongsTo(
related: User::class,
foreignKey: 'sender_id',
);
}
public function team(): BelongsTo
{
return $this->belongsTo(
related: Team::class,
foreignKey: 'team_id',
);
}
}
我们可以将这些关系镜像到其他模型上,因为它们是相关的。不过,默认情况下,我还是会在每个模型上添加关系的另一面,以澄清模型之间的绑定关系,不管它是否严格需要。这是我养成的一个习惯,以帮助别人理解数据模型本身。
现在我们已经从建模的角度为我们的应用程序建立了基础。我们需要考虑安装一些能帮助我们的包。对于我的应用程序,我使用Laravel Livewire来控制用户界面。然而, 我不会在本教程中详细介绍,因为我想确保我专注于事件源方面。
就像我建立的大多数项目一样,无论大小,我都采用了模块化布局的应用--领域驱动设计方法。这只是我的做法,不要觉得你必须自己遵循,因为这是非常主观的。
我的下一步是设置我的域,对于这个演示,我只有一个域。文化。在Culture中,我为我可能需要的一切创建了命名空间。但我将通过它,让你了解这个过程。
第一步是安装一个包,使我能够在Laravel中使用事件源。为此, 我使用了一个Spatie包,它为我做了很多的后台工作。让我们用composer来安装这个包。
composer require spatie/laravel-event-sourcing
一旦安装完毕, 确保你遵循软件包的安装说明 - 因为配置和迁移需要发布.一旦这个安装正确,运行你的迁移,这样你的数据库就可以处于正确的状态了。
php artisan migrate
现在我们可以开始考虑如何实现事件源了。你可以通过以下几种方式来实现:用投影仪来投影你的状态或聚合体。
投影器是一个位于你的应用程序中的类,它处理你派发的事件。这些将改变你的应用程序的状态。它比简单地更新你的数据库更进一步。它坐落在中间,捕捉事件,存储它,然后做它需要的改变--然后为应用程序 "投射 "新的状态。
另一种方法,也是我喜欢的方法,就是聚合--这些是类,像投影仪一样,为你处理应用程序的状态。在我们的应用程序中,我们不需要自己触发事件,而是让聚合体来为我们做这件事。把它想象成一个中继器,你要求中继器做什么,它就会为你处理。
在我们创建第一个聚合之前,有一些工作要在后台进行。我非常喜欢为每个聚合创建一个事件存储,这样查询会更快,而且存储不会很快被填满。这在包的文档中有所解释,但我将亲自指导你,因为它在文档中并不是最清楚的。
第一步是创建一个模型和迁移,因为你需要一个方法来查询它在未来的报告等。运行下面的artisan命令来创建这些。
php artisan make:model CelebrationStoredEvent -m
下面的代码是你在迁移的方法中需要的。
public function up(): void
{
Schema::create('celebration_stored_events', static function (Blueprint $table): void {
$table->id();
$table->uuid('aggregate_uuid')->nullable()->unique();
$table
->unsignedBigInteger('aggregate_version')
->nullable()
->unique();
$table->integer('event_version')->default(1);
$table->string('event_class');
$table->json('event_properties');
$table->json('meta_data');
$table->timestamp('created_at');
$table->index('event_class');
$table->index('aggregate_uuid');
});
}
正如你所看到的,我们为我们的事件收集了相当多的数据。现在,这个模型就简单多了。它应该看起来像这样。
declare(strict_types=1);
namespace App\Models;
use Spatie\EventSourcing\StoredEvents\Models\EloquentStoredEvent;
final class CelebrationStoredEvent extends EloquentStoredEvent
{
public $table = 'celebration_stored_events';
}
由于我们正在扩展EloquentStoredEvent 模型,我们需要做的就是改变它所看的表。该模型的其他功能已经在父类上到位了。
要使用这些模型,你必须创建一个资源库来查询这些事件。这是一个相当简单的存储库--然而,它是一个重要的步骤。我把我的加入到我的领域代码中,在src/Domains/Culture/Repositories/ ,但请随意加入你的对你来说最有意义的地方。
declare(strict_types=1);
namespace Domains\Culture\Repositories;
use App\Models\CelebrationStoredEvent;
use Spatie\EventSourcing\StoredEvents\Repositories\EloquentStoredEventRepository;
final class CelebrationStoredEventsRepository extends EloquentStoredEventRepository
{
public function __construct(
protected string $storedEventModel = CelebrationStoredEvent::class,
) {
parent::__construct();
}
}
现在我们有了一个存储事件和查询它们的方法,我们可以继续我们的聚合本身。同样,我把我的存储在我的领域里,但你也可以把你的存储在你的应用环境里。
declare(strict_types=1);
namespace Domains\Culture\Aggregates;
use Domains\Culture\Repositories\CelebrationStoredEventsRepository;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
use Spatie\EventSourcing\StoredEvents\Repositories\StoredEventRepository;
final class CelebrationAggregateRoot extends AggregateRoot
{
protected function getStoredEventRepository(): StoredEventRepository
{
return app()->make(
abstract: CelebrationStoredEventsRepository::class,
);
}
}
到目前为止,这个聚合体除了为我们连接到正确的事件存储外,不会做任何事情。为了让它开始追踪事件,我们首先需要创建它们。但在这之前,我们需要停下来思考一下。我们想在事件中存储什么数据?我们想存储我们需要的每一个属性吗?还是我们想存储一个数组,就像它来自一个表单一样?我使用这两种方法,因为为什么要保持简单?我在我所有的事件中使用数据传输对象,以确保始终保持上下文,并始终提供类型安全。
我建立了一个包,使我能够更容易地做到这一点。你可以通过下面的composer命令来安装它,自由地使用它。
composer require juststeveking/laravel-data-object-tools
和以前一样,我把我的数据对象默认保留在我的领域内,但在对你最有意义的地方添加。我创建了一个叫Celebration 的数据对象,我可以通过它来传递给事件和聚合体。
declare(strict_types=1);
namespace Domains\Culture\DataObjects;
use JustSteveKing\DataObjects\Contracts\DataObjectContract;
final class Celebration implements DataObjectContract
{
public function __construct(
private readonly string $reason,
private readonly string $message,
private readonly int $user,
private readonly int $sender,
private readonly int $team,
) {}
public function userID(): int
{
return $this->user;
}
public function senderID(): int
{
return $this->sender;
}
public function teamUD(): int
{
return $this->team;
}
public function toArray(): array
{
return [
'reason' => $this->reason,
'message' => $this->message,
'user_id' => $this->user,
'sender_id' => $this->sender,
'team_id' => $this->team,
];
}
}
当我升级到PHP 8.2时,这将会容易得多,因为我可以创建只读的类--是的,我的包已经支持它们。
现在我们有了我们的数据对象。我们可以回到我们要存储的事件上了。我把我的称为CelebrationWasCreated ,因为事件名称应该总是用过去式。让我们看一下这个事件。
declare(strict_types=1);
namespace Domains\Culture\Events;
use Domains\Culture\DataObjects\Celebration;
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;
final class CelebrationWasCreated extends ShouldBeStored
{
public function __construct(
public readonly Celebration $celebration,
) {}
}
因为我们使用的是数据对象,所以我们的类仍然很干净。所以,现在我们有了一个事件--和一个我们可以发送的数据对象,我们需要考虑如何触发它。这就使我们回到了我们的聚合体本身,所以让我们在聚合体上创建一个方法,我们可以用它来做这件事。
declare(strict_types=1);
namespace Domains\Culture\Aggregates;
use Domains\Culture\DataObjects\Celebration;
use Domains\Culture\Events\CelebrationWasCreated;
use Domains\Culture\Repositories\CelebrationStoredEventsRepository;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
use Spatie\EventSourcing\StoredEvents\Repositories\StoredEventRepository;
final class CelebrationAggregateRoot extends AggregateRoot
{
protected function getStoredEventRepository(): StoredEventRepository
{
return app()->make(
abstract: CelebrationStoredEventsRepository::class,
);
}
public function createCelebration(Celebration $celebration): CelebrationAggregateRoot
{
$this->recordThat(
domainEvent: new CelebrationWasCreated(
celebration: $celebration,
),
);
return $this;
}
}
在这一点上,我们有一个方法来要求一个类来记录一个事件。然而,这个事件还不会被持久化--这是后话了。另外,我们没有以任何方式改变我们的应用程序的状态。那么,我们如何做这个事件源的部分呢?对我来说,这部分是在Livewire中实现的,现在我将向你介绍。
我喜欢通过调度一个事件来管理这个过程,因为这样做更有效率。如果你考虑一下你如何与一个应用程序互动,你可以从网上访问它,通过API端点发送请求,或者运行CLI命令的事件--也许是一个CRON作业。在所有这些方法中,通常情况下,你想要一个即时的响应,或者至少你不希望在旁边等待。我将向你展示我的Livewire组件上的方法,我曾用它来做这件事。
public function celebrate(): void
{
$this->validate();
dispatch(new TeamMemberCelebration(
celebration: Hydrator::fill(
class: Celebration::class,
properties: [
'reason' => $this->reason,
'message' => $this->content,
'user' => $this->identifier,
'sender' => auth()->id(),
'team' => auth()->user()->current_team_id,
]
),
));
$this->closeModal();
}
我从组件中验证用户的输入,分派一个可以处理的新任务,并关闭模态。我使用我的包将一个新的数据对象传递给作业。它有一个门面,允许我用一个属性数组对类进行水化处理--到目前为止,它工作得很好。那么这个工作是做什么的呢?让我们看一下。
declare(strict_types=1);
namespace App\Jobs\Team;
use Domains\Culture\Aggregates\CelebrationAggregateRoot;
use Domains\Culture\DataObjects\Celebration;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
final class TeamMemberCelebration implements ShouldQueue
{
use Queueable;
use Dispatchable;
use SerializesModels;
use InteractsWithQueue;
public function __construct(
public readonly Celebration $celebration,
) {}
public function handle(): void
{
CelebrationAggregateRoot::retrieve(
uuid: Str::uuid()->toString(),
)->createCelebration(
celebration: $this->celebration,
)->persist();
}
}
我们的工作在其构造函数中接受数据对象,然后在处理时存储它。当作业被处理时,它使用CelebrationAggregateRoot ,按UUID检索一个集合,然后调用我们先前创建的createCelebration 方法。在它调用这个方法之后,它在聚合体本身上调用persist 。这就是为我们存储事件的原因。但是,同样,我们还没有改变我们的应用程序的状态。我们所做的只是存储了一个无关的事件,而没有创建我们想要创建的庆祝活动?那么我们缺少什么呢?
我们的事件也需要被处理。在另一种方法中,我们使用一个投影仪来处理我们的事件,但我们必须手动调用它们。这里是一个类似的过程,但是我们的聚合体触发了事件,我们仍然需要一个投影仪来处理事件,并突变我们的应用状态。
让我们创建我们的投影仪,我称之为处理程序--因为它们处理事件。但我将让你决定你希望如何命名你的投影仪。
declare(strict_types=1);
namespace Domains\Culture\Handlers;
use Domains\Culture\Events\CelebrationWasCreated;
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;
use Infrastructure\Culture\Actions\CreateNewCelebrationContract;
final class CelebrationHandler extends Projector
{
public function __construct(
public readonly CreateNewCelebrationContract $action,
) {}
public function onCelebrationWasCreated(CelebrationWasCreated $event): void
{
$this->action->handle(
celebration: $event->celebration,
);
}
}
我们的投影仪/处理程序,不管你怎么称呼它,将从容器中为我们解析--然后它将寻找一个以on 为前缀的方法,后面跟着事件名称本身。所以在我们的例子中,onCelebrationWasCreated 。在我的例子中,我使用一个动作来执行事件的实际逻辑--单个类做一个工作,可以很容易地被伪造或替换。因此,我们又一次把树追到了下一个类。这个动作,对我来说就是这个样子的。
declare(strict_types=1);
namespace Domains\Culture\Actions;
use App\Models\Celebration;
use Domains\Culture\DataObjects\Celebration as CelebrationObject;
use Illuminate\Database\Eloquent\Model;
use Infrastructure\Culture\Actions\CreateNewCelebrationContract;
final class CreateNewCelebration implements CreateNewCelebrationContract
{
public function handle(CelebrationObject $celebration): Model|Celebration
{
return Celebration::query()->create(
attributes: $celebration->toArray(),
);
}
}
这是当前动作的实现。正如你所看到的,我的动作类本身实现了一个契约/接口。这意味着我把接口与我的服务提供者中的特定实现绑定在一起。这使我能够轻松地创建测试的双胞胎/模拟/替代方法,而不会对需要执行的实际动作产生影响。这并不是严格意义上的事件源,而是一般的编程问题。我们确实有一个好处,就是我们的投影仪可以重放。所以,如果因为某些原因,我们离开了Laravel Eloquent,也许我们使用了其他的东西,我们可以创建一个新的动作 - 在我们的容器中绑定实现,重放我们的事件,它应该都是正常的。
在这个阶段, 我们正在存储我们的事件,并有一个方法来突变我们的应用程序的状态 - 但是我们有吗?我们需要告诉事件源库,我们已经注册了这个投射器/处理程序,这样它就知道在事件中触发它。通常,我会为每个域创建一个EventSourcingServiceProvider ,这样我就可以在一个地方注册所有的处理程序。我的看起来像下面这样。
declare(strict_types=1);
namespace Domains\Culture\Providers;
use Domains\Culture\Handlers\CelebrationHandler;
use Illuminate\Support\ServiceProvider;
use Spatie\EventSourcing\Facades\Projectionist;
final class EventSourcingServiceProvider extends ServiceProvider
{
public function register(): void
{
Projectionist::addProjector(
projector: CelebrationHandler::class,
);
}
}
剩下的就是要确保这个服务提供者再次被注册。我为每个域创建一个服务提供者来注册子服务提供者--但这是另一个故事和教程。
现在,当我们把这一切放在一起时。我们可以要求我们的聚合体创建一个庆典,它将记录事件并将其持久化在数据库中,作为一个副作用,我们的处理程序将被触发,用新的变化来突变应用程序的状态。
这似乎有点啰嗦,对吗?有更好的方法吗?有可能,但在这一点上,我们知道我们的应用程序的状态何时被改变。我们知道为什么会发生这样的变化。另外,由于我们的数据对象,我们知道谁在什么时候做了这些改变。因此,这可能不是最直接的方法,但它允许我们更多地了解我们的应用程序。