saucy / saucy
Requires
- php: ^8.2
- ext-pdo: *
- eventsauce/backoff: ^1.2
- eventsauce/eventsauce: ^3.5
- laravel/framework: ^11.21
- league/construct-finder: ^1.3
- robertbaelde/attribute-finder: ^0.1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.49
- larastan/larastan: ^2.0
- orchestra/testbench: ^9
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2024-09-23 07:22:46 UTC
README
警告,此文档是临时占位符,直到完整文档准备就绪。在/workbench/app目录中查找示例用法。
待办事项
- 添加测试 & phpstan
- 添加 Eloquent Projector
- 添加 reactors & process managers
- 为命令和查询添加中间件(例如,检查用户是否有权执行命令或查询)
- (可能)添加文档存储
- 重播功能
- 跟踪 UI
- 批量提交投影
灵感/依赖
Saucy 受到 EventSauce 的极大启发,并部分使用其组件。(链接)此外,事件基础设施受到 Eventious 的启发。(链接)Ecotone 也是此项目的另一个灵感来源。
用法
Saucy 主要由以下 3 部分组成
- CommandBus:自动装配 CommandBus
- QueryBus:自动装配 QueryBus
- Projections:通过添加一个属性即可简单地注册的投影
命令总线
命令可以由 aether 事件源聚合根或命令处理器处理。当聚合根处理命令时,Saucy 会自动处理聚合检索和持久化。
为了使用命令总线,您可以使用任何类作为命令。例如
final readonly class CreditBankAccount { public function __construct( public BankAccountId $bankAccountId, public int $amount, ) { } }
接下来,使用 CommandHandler
注解标注命令处理器,Saucy 会完成其余工作。
在聚合根外部
class SomeCommandHandler { // Saucy automatically binds the command used as first argument to this handler method #[\Saucy\Core\Command\CommandHandler] public function handleCommand(CreditBankAccount $creditBankAccount): void { // do your magic here } }
在聚合根内部
// Saucy needs this in order to know what argument in your command can be used as aggregate root ID #[Aggregate(aggregateIdClass: BankAccountId::class, 'bank_account')] final class BankAccountAggregate implements AggregateRoot { use AggregateRootBehaviour; #[CommandHandler] public function credit(CreditBankAccount $creditBankAccount): void { $this->recordThat(new AccountCredited($creditBankAccount->amount)); } }
我们可以这样向总线执行命令
// ideally inject the class in the constructor, and not use make everywhere, // this is just for demo purpose $commandBus = $this->app->make(\Saucy\Core\Command\CommandBus::class); $commandBus->handle($command);
查询总线
查询总线可以用于查询领域中的信息。它使用与命令总线类似的原则。主要区别是查询可以返回一些内容。
定义查询
/** @implements Query<int> */ final readonly class GetBankAccountBalance implements Query { public function __construct( public BankAccountId $bankAccountId ){ } }
在查询 doc-bloc 中,我们可以提示预期的返回类型(在这种情况下是 int,但可以是任何类)。
要处理查询,请用 QueryHandler
注解负责处理的方法。类似于命令总线,第一个参数是处理器方法绑定的查询。
class SomeQueryHandler { #[\Saucy\Core\Query\QueryHandler] public function getGetBankAccountBalance(GetBankAccountBalance $getBankAccountBalance): int { return $this->repository->getBalanceFor($getBankAccountBalance->bankAccountId); } }
我们可以这样向总线执行命令
// ideally inject the class in the constructor, and not use make everywhere, // this is just for demo purpose $queryBus = $this->app->make(\Saucy\Core\Query\QueryBus::class); $result = $queryBus->query($command);
可以使用的一种不错的设计模式是将响应特定投影器的数据的项目处理器放置在相应的投影器内部。所有回答查询的逻辑都可以在一个地方找到。有关示例,请参阅有关投影器的部分。
投影
投影可以将事件映射到用于查询信息的专用读取模型。
我们可以识别两种不同的投影类型
- 所有流投影:这些投影器监听所有事件的流。允许读取模型“连接”来自不同聚合根的数据。这在高并发系统中会带来投影延迟的成本。
- 聚合投影:这些投影器在每个聚合根实例上独立运行。对于大多数用例来说,这已经足够,并且具有并行重播的好处(两个不同的聚合根可以并行重播)。
投影的简单形式如下
#[\Saucy\Core\Projections\Projector] class MyProjection extends TypeBasedConsumer { public function doSomething(AccountCredited $event) { // This method is called for every new AccountCredited event. } }
在事件处理方法的第二个参数中,您还可以请求 MessageConsumeContext,该上下文包含有关事件和重播的信息,可能有用。
要将 AllStreamProjector 更改为 AggregateProjector,将 Projector 属性更改为 AggregateProjector 属性,并传递投影器应范围到的聚合类的类名。
#[AggregateProjector(BankAccountAggregate::class)] class MyProjection extends TypeBasedConsumer { public function doSomething(AccountCredited $event) { // This method is called for every new AccountCredited event. } }
通常您希望将读取模型状态持久化到数据库中。为了避免繁琐的重复,Saucy 包含了一个 IlluminateDatabaseProjector。这个投影仪自动将投影范围限定在聚合根的标识符上,并提供了以下方法来更改数据库中的状态
protected function upsert(array $array): void protected function update(array $array): void protected function increment(string $column, int $amount = 1): void protected function create(array $array): void protected function find(): ?array // returns null when instance could not be found protected function delete(): void
您的投影应包含 schema 方法,用于定义投影的数据库表结构。投影的表名可以通过覆盖父类的 tableName
方法来设置。如果该方法未被覆盖,则使用默认值 projection_{{ProjectionClassName}}
。
protected function schema(Blueprint $blueprint): void { // The id column type should be equal to the aggregateRootId type the projection is bound to. // It's possible to override the `idColumnName` method in order to use a custom name $blueprint->ulid($this->idColumnName())->primary(); $blueprint->integer('balance'); }
使用 IlluminateDatabaseProjector 的完整示例
#[AggregateProjector(BankAccountAggregate::class)] final class BalanceProjector extends IlluminateDatabaseProjector { public function ProjectAccountCredited(AccountCredited $accountCredited): void { $bankAccount = $this->find(); if($bankAccount === null){ $this->create(['balance' => $accountCredited->amount]); return; } $this->increment('balance', $accountCredited->amount); } // Projectors can be combined with QueryHandlers. QueryHandlers aren't magically scoped to the aggregate ID. // When you want to use the provided database access methods, you can first scope the projector to the right aggregate by using the scopeAggregate() method. // It's also possible to query the table directly using $this->queryBuilder #[QueryHandler] public function getBankAccountBalance(GetBankAccountBalance $query): int { $this->scopeAggregate($query->bankAccountId); $bankAccount = $this->find(); if($bankAccount === null){ return 0; } return $bankAccount['balance']; // or use queryBuilder $bankAccount = $this->queryBuilder->where($this->idColumnName(), $query->bankAccountId->toString())->first(); } protected function schema(Blueprint $blueprint): void { $blueprint->ulid($this->idColumnName())->primary(); $blueprint->integer('balance'); } }
除了 illuminate 数据库投影仪之外,我们还支持 Eloquent 模型作为读取模型。为了做到这一点,我们希望保护我们投影的字段不被其他代码片段更新。为此,向您想进行投影的模型添加 use HasReadOnlyFields;
特性。现在我们可以这样创建我们的 Eloquent 投影仪
use HasReadOnlyFields;
特性到您想进行投影的模型。现在我们可以创建我们的 Elqouent 投影仪如下
#[AggregateProjector(BankAccountAggregate::class)] final class BankAccountProjector extends EloquentProjector { protected static string $model = BankAccountModel::class; public function handleAccountCredited(AccountCredited $accountCredited): void { $bankAccount = $this->find(); if($bankAccount === null){ $this->create(['balance' => $accountCredited->amount]); return; } $this->increment('balance', $accountCredited->amount); } }