自从建立和支持基于事件采购的生产服务以来,已经有一年多了,在那里我们没有设计标准化的关系数据库架构,而是将我们域的持久性层建模为不可分割的事件流。
使用这种方法,读取模型是独立的,您可以创建对应用程序有用的任何访问模式,无论是关系数据库架构还是更专业的内容。
我们最简单的示例是“钱包”汇总,该总体记录了基于接收和支出学分的事件。在下面的示例中,我们看到了钱包事件流和数据库表投影到了,并指出事件流的信息和上下文比我们的阅读模型实际关心的要多得多(用户在任何情况下的积分总数一次)。
+------------------------+--------+----------------+---------------------------------------------------------+
| Time | Stream | Event | Payload |
+------------------------+--------+----------------+---------------------------------------------------------+
| 9/02/2022 02:44:19 PM | wallet | CreditsGranted | { |
| | | | "amount": 1 |
| | | | } |
| 10/02/2022 08:38:48 AM | wallet | CreditsSpent | { |
| | | | "amount": 1, |
| | | | "type": "jobLaunch", |
| | | | "typeMetadata": { |
| | | | "jobId": "9348e9b3-43b8-4531-b79c-d70d0cb66ba0" |
| | | | } |
| | | | } |
+------------------------+--------+----------------+---------------------------------------------------------+
MariaDB [main]> select aggregate_id, credits from account_projection where credits > 0;
+--------------------------------------+---------+
| aggregate_id | credits |
+--------------------------------------+---------+
| 911357c8-4b63-4e5a-8b5d-d38eff30a7cb | 7 |
| ff452810-32d3-483b-9d93-064a1df11190 | 9 |
+--------------------------------------+---------+
我们的Wallet
汇总,负责录制事件CreditsGranted
和CreditsSpent
:
final class Wallet implements AggregateRoot {
use AggregateRootBehaviourWithRequiredHistory;
private int $totalCredits = 0;
public static function create(WalletId $id): static {
return new static($id);
}
public function grantCredits(int $amount): void {
if ($amount < 1) {
throw CreditAddException::because('Cannot grant an amount of credits less than 1');
}
$this->recordThat(new CreditsGranted($amount));
}
protected function applyCreditsGranted(CreditsGranted $event): void {
$this->totalCredits += $event->amount;
}
public function getTotalCredits(): int {
return $this->totalCredits;
}
public function spendCredits(int $amount, CreditSpendTypeInterface $spendType): void {
if ($amount < 1) {
throw CreditSpendException::because('Cannot spend an amount of credits less than 1');
}
if ($amount > $this->totalCredits) {
throw CreditSpendException::because('Not enough credits in wallet');
}
$this->recordThat(new CreditsSpent($amount, $spendType));
}
protected function applyCreditsSpent(CreditsSpent $event): void {
$this->totalCredits -= $event->amount;
}
}
随着快速旅行的发展,这种模式的一些利弊是有哪些使用的。
灵活的读取模型ð
能够快速设计模式并以后重写它非常有用。当快速原型制作功能(例如上面的钱包)时,我们的第一个版本仅在试图在我们的平台上启动工作时验证用户的信用余额,尽管在任何时候我们都可以将读取模型扩展到交易的历史记录。
另一个有形的好处是消除了干燥数据库设计的概念。我们的应用程序是一个两面市场,当事方围绕“工作”总体进行交易。访问各方在生命周期期间获得与工作相关的各种信息都大不相同。而不是拥有一个经过仔细访问和保护的单个实体,而是将两个完全不同的“职位所有者”和“工作参与者”模式投影,这极大地简化了我们应用程序的访问控制和复杂性。当两党的经验工作时,您都有一个针对每个角色量身定制的读取模型,您可以自信地将其传递给他们。
审计踪迹ð
与传统数据库模式的一场真正的斗争正在弄清楚值得在给定时刻保持多少信息。创建东西时您想存储吗?大概。上次更新的时候呢?是的,这似乎很有用。什么时候更改单个属性呢?也许,不是吗?每个属性的每次更改以及谁开始更改的时间戳呢?当然不是。
事件流将时间带入方程式,成为一流的公民。与其拥有对正在发生的事情的狭窄见解的时间戳列或日志,不变的事件的集合显示了当事件发生时,并在所有情况下启动了事件。
这对审核很有帮助,但我也注意到我们的应用程序似乎越来越频繁地冒出有关谁做某事以及何时做的事情的信息,我认为可以测量的改进了我们的一些接口。我们ACL系统前端的一个示例:
更多工具ð
使用读写模型,加上编排重建写入模型的工具,实现此模式肯定需要更多的工具和代码。
还将有一个需要更多投资的阶段。目前,我们截断并重建了所有部署的所有预测,因为在目前的事件中,我们需要<30秒。在未来的某个时间点,我们可能需要更具选择性,对它们进行更迭代的重建或考虑其他构建步骤以简化重建为新的模式。
我们的重建命令:
bin/console dpa:rebuild-projections
Rebuilding projections
+----------------------+---------------+
| Aggregate | Message Count |
+----------------------+---------------+
| access_control_list | 1880 |
...
| wallet | 16 |
| total | 7758 |
+----------------------+---------------+
Finished batch 0 default/batch0: 32.00 MiB - 917 ms
...
Finished batch 14 default/batch14: 40.00 MiB - 958 ms
Total events: 7,758 in default/rebuild: 42.00 MiB - 26310 ms
Projections rebuilt
bin/console dpa:update-projection-status --done
Existing status: rebuilding
New status: up to date
某些领域的选择不佳
有些域,我认为该模式并没有增加太多价值。到目前为止,我认为每当我们试图从其他地方策划的另一个服务,系统或过程中的信息提取,同步或表示信息时,使用事件采购都会增加开销,并没有提供太大的好处。
真理的来源已经被安置在其他地方,并理解一条信息从一项服务到另一个服务的边界何时不太有用。
值得庆幸的是,在这种情况下,从事件采购中转移非常容易:将架构投影,交换您的应用程序代码以直接读取并写入新模式,停止重建投影。
考虑到这些警告,我仍然认为这种模式非常有用,并且已经偿还了多次投资。
如果您有兴趣实施这种模式,我发现EventSauce是一个很棒的参考实施,社区和学习资源。
由dall-e生成的标头提示:一个带有数字功能的钱包,信封有序地堆叠,数字艺术