学习Laravel中的事件源

85 阅读11分钟

事件源是一个在过去几年中在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,
        );
    }
}

剩下的就是要确保这个服务提供者再次被注册。我为每个域创建一个服务提供者来注册子服务提供者--但这是另一个故事和教程。

现在,当我们把这一切放在一起时。我们可以要求我们的聚合体创建一个庆典,它将记录事件并将其持久化在数据库中,作为一个副作用,我们的处理程序将被触发,用新的变化来突变应用程序的状态。

这似乎有点啰嗦,对吗?有更好的方法吗?有可能,但在这一点上,我们知道我们的应用程序的状态何时被改变。我们知道为什么会发生这样的变化。另外,由于我们的数据对象,我们知道谁在什么时候做了这些改变。因此,这可能不是最直接的方法,但它允许我们更多地了解我们的应用程序。