跨聚合的唯一键
聚合独自做出决定,所以它看不到另一个聚合是否已经用了某个值。当一个值必须在聚合之间唯一时,例如用户名、邮箱地址或品牌名,就由决定来预留它。一个预留同一时刻只被一个聚合持有,与聚合的事件一起提交,事件没有写入时就放弃。本页让 eShop 的品牌名保持唯一。
预留一个键
DecideScope.reserve(namespace, key) 在 namespace 中为正在做决定的聚合预留 key,另一个聚合持有它时返回 false。品牌在取名时预留它的名字:
kotlin
/** Rejects a blank name or one another brand holds; null when this brand may take [name]. */
private suspend fun take(name: String, scope: DecideScope): Decision? = when {
name.isBlank() -> Decision.reject(Rejections.BLANK_NAME, "A brand needs a name.")
!scope.reserve(CatalogNames.BRAND_NAMES, CatalogNames.key(name)) ->
Decision.reject(Rejections.NAME_TAKEN, "Another brand is named \"${name.trim()}\".")
else -> null
}- 命名空间指明什么是唯一的:
eshop-catalog.brand-name。品牌和分类在两个命名空间中预留,所以品牌和分类可以同名。 - 键就是这个值,按唯一性的含义规范化:catalog 会去掉名字两端的空白并转成小写,所以 "Contoso" 和 " contoso " 是同一个名字。
- 唯一性的范围是聚合的限界上下文和租户:另一个租户可以持有同一个键。
reserve(namespace, key, ReservationTenancy.SYSTEM)让一个键在上下文的所有租户之间唯一。 reserve是一个suspend函数,所以预留键的决定函数要用 Kotlin 写。
预留会经历什么
reserve为这个聚合把键记为待定,并带一个过期时间。- 决定的事件追加之后,预留被确认。
- 当确定事件没有追加时(决定被拒绝、版本冲突后命令重新决定,或者决定失败),这次尝试放弃它的预留。进程先崩溃时,就让预留过期。
聚合已经持有的键,无论某次尝试做了什么都保持持有,所以重新决定一条命令永远不会丢掉它。
释放一个键
值改变时释放它旧的键。DecideScope.release(namespace, key) 在事件追加之后释放聚合持有的一个键:释放与事件一起提交,事件没有追加时什么都不释放。重命名在同一次决定中预留新键、释放旧键:
kotlin
@OnCommand(produces = [BrandRenamed::class])
suspend fun onCommand(command: RenameBrand, scope: DecideScope): Decision {
if (command.name.trim() == state.name) return Decision.NoOp
take(command.name, scope)?.let { return it }
// A new spelling of the same name keeps its key.
if (CatalogNames.key(command.name) != CatalogNames.key(state.name)) {
scope.release(CatalogNames.BRAND_NAMES, CatalogNames.key(state.name))
}
return Decision.events(BrandRenamed(command.name.trim()))
}删除聚合不会释放它的任何键,所以恢复的聚合仍持有它们。应当让出某个键的删除,要在删除之前,在聚合自己的命令里释放它。
预留保存在哪里
预留保存在服务的存储里,与它的日志放在一起,所以确认与它所属的提交一样持久。它们取代了 9.x 的 PrepareKey;migrate-data 会导入 9.x 已确认的键(迁移数据)。
测试
aggregateSpec 对着一个内存存储运行预留。givenReservation 先为另一个聚合预留一个键,这样测试可以检查键被占用时命令会被拒绝;holds(namespace, key) 说明被测的聚合是否持有某个键:见测试领域代码。