编写聚合
聚合是做出决定的单位:它一次接收一条命令,查看自己的状态,决定发生哪些事件。本页以 eShop 的订单为例写一个聚合。背后的模型见决定与演化。
代码放在哪里
一个聚合跨越它所在上下文的两个模块:
| 模块 | 包含 | 由谁读取 |
|---|---|---|
*-api | 命令和事件,普通的数据类 | 服务本身,以及发送它的命令或读取它的事件的每个对端 |
*-domain | 根类及其状态类 | 只有服务本身 |
编译器在构建期读取领域模块,生成聚合的模型、它的绑定以及它那部分 Manifest。运行时不扫描任何东西。
编写命令
命令是 api 模块里的一个数据类。它承载调用方要求的内容,从不承载调用方是谁:租户、所有者和空间来自调用方,期望版本来自 If-Match。聚合 ID 来自路由或发送命令的 Saga,除非命令体本身已经指明了聚合(在命令体中指明聚合)。
/**
* Submits an order of [items]. The basket context sends it when its buyer checks out, with the order's id fixed by the
* basket. The order belongs to the buyer: a saga's command carries the scope of the basket it is derived from, whose
* owner is its buyer, and the order takes its owner from the command that creates it. An order has
* items, each valid ([OrderItem]'s constraints): the gateway checks that before the order decides.
*/
@CreateAggregate
@Summary("Submit an order")
data class SubmitOrder(
@field:NotEmpty @field:Valid val items: List<OrderItem>,
)@Summary 为命令提供一行摘要,用于 Manifest 和 OpenAPI 文档。不带数据的命令是一个 data object,例如 ShipOrder。
检查它的形状
只关乎命令本身的规则,例如数量为正、名称不为空白、订单有条目,是命令类上的 Jakarta Bean Validation 约束,用 Kotlin 的 @field: 目标:
/**
* Adds [quantity] of [productId] at [unitPrice] to the basket, creating the basket on its first item. Adding a product
* already in the basket adds to its quantity, at the new price. The constraints are the command's shape: the gateway
* checks them before the basket decides, and answers a violation `wow.command.invalid` (400).
*/
@AllowCreate
@Summary("Add an item to the basket")
data class AddBasketItem(
val productId: String,
@field:PositiveOrZero val unitPrice: BigDecimal,
@field:Positive val quantity: Int,
)网关在聚合决定之前检查它接收的每条命令的约束:来自路由、进程内 CommandClient、Saga、定时器或另一个上下文的命令都一样。违反约束时回答 wow.command.invalid(400),problem 列出每个违反项的属性路径(items[0].quantity)、约束(positive,即注解在线上的名字)和消息。被拒绝的定时器命令就是该定时器被拒绝的命令:连同违反项记入日志,不会被决定,也不再重试。属性标注 @field:Valid 时检查嵌套的值,SubmitOrder 的条目就是这样。api 模块依赖 jakarta.validation:jakarta.validation-api,服务需要一个 Bean Validation 实现(托管在 Spring Boot 上)。
约束是命令契约的一部分。它们是 Manifest 和 OpenAPI 文档中命令 schema 的校验关键字(quantity 是 {"type": "integer", "format": "int32", "exclusiveMinimum": 0}),生成的客户端或 Swagger UI 在发送之前就能看到它们。收紧一个约束,例如提高最小值,是破坏性改变(Manifest 与兼容)。
需要聚合状态或调用方的规则留在命令函数里,作为带错误码的拒绝(拒绝与错误码):下面订单的函数已经没有要检查的了。
命令能否创建它的聚合,由它的类决定:
| 命令类 | 何时被接受 |
|---|---|
@CreateAggregate | 聚合尚不存在;否则 wow.aggregate.exists |
@AllowCreate | 无论聚合是否存在 |
| 两者都没有 | 聚合已存在;否则 wow.aggregate.not-found |
每条命令的路由是 POST /<context>/<aggregate>/{id}/<command>,命令名采用 kebab case,与 Manifest 列出的路由一致:POST /eshop-ordering/order/{id}/submit-order。创建命令还提供不带 {id} 的路由,由服务端为聚合分配 ID。准入会先查找同一个请求 ID 已经创建过的聚合并复用它的 ID,所以同一个创建发两次只会创建一个聚合。ID 即所有者 ID 的聚合(每个所有者一个)的每条命令也都提供不带 {id} 的路由,指调用方自己的聚合,并且不生成 ID(每个所有者一个聚合)。
在命令体中指明聚合
有些命令的业务数据里本来就有它的聚合 ID:一次补货指明要给哪个商品的库存补货。在这个属性上标注 @AggregateId,命令体就指明了聚合,发送方不必再另给 ID:
@AllowCreate
@Summary("Restock a product")
data class RestockProduct(
@AggregateId val productId: String,
@field:Positive val quantity: Int,
)这样的命令只提供 POST /<context>/<aggregate>/<command> 一条路由,不带 {id},作用于命令体指明的聚合。Saga 用 CommandRequest.of(body) 发送它,客户端发送时也不带 ID。发往另一个上下文的这类命令进入该上下文的命令主题时按聚合分区,同一个聚合的命令因此保持顺序。准入在授权之前读取命令体,所以策略看到的仍是聚合 <aggregate>/<id>。该聚合的生命周期命令保留带 {id} 的路由。
| 属性 | 承载 |
|---|---|
有字段或构造参数的 String | ID,即命令体里的值 |
计算属性 String,如 val stockId: String get() = "$warehouseId-$sku" | 由其他字段算出的 ID:只发送那些字段的发送方可以不带它,由服务算出 |
可创建聚合的命令上的 String? | ID;为 null 时交给服务端分配,与任何不指明 ID 的创建一样,重试沿用第一次的 ID |
准入会检查这个 ID:空白的 ID 是 wow.command.malformed(400);在命令体之外又指明了另一个聚合的请求(例如 Kotlin 客户端的 CommandRequest.to(ref, body))同样是 wow.command.malformed。指明同一个聚合的请求会被接受。
这个属性由命令类或它实现的接口声明,是 public 或 internal,并且 Wow 的 JSON 会写出它(没有 @JsonIgnore),所以每个发送方的命令体里都有它。一条命令只有一个属性指明聚合。编译器拒绝把它用在 @Unaddressed 命令或挂载命令上(它们的聚合由转换器找到),也拒绝用在 ID 即所有者 ID 的聚合上。接收这类命令的 CommandTransformer 会让服务在启动时失败。
Wow 10 只按 @AggregateId 从命令体取得命令的聚合,从不按属性名推断。因此,命令有名为 id 的 String 属性却没有 @AggregateId,而它本可以指明聚合时,编译器会发出警告:这个 id 可以是存储的或计算的,可以是命令自己的,也可以来自接口,例如 9.x 的 Identifier.id。如果这个 id 指明的是聚合,就给它标注 @AggregateId:命令体指明聚合的 9.x 命令都需要这样做,其中包括 Saga 发送时不带 ID 的创建命令。如果 9.x 从路径绑定它,它只是重复了路由:删掉它,改读 command.ref.id(命令体承载什么)。指明其他东西的 id 保留不动,在命令类上标注 @Suppress("UnmarkedAggregateId") 即可消除警告。
只在内部受理的命令
有些命令记录的是服务自己的流程得出的结果,而不是客户端的请求:支付网关的答复、定时器的判定、只由 Saga 走的一步。在命令类上标注 @InternalCommand,公开端口就不受理它:
/**
* Records that the gateway approved the payment, under its [transactionId]. The gateway saga sends it, and only it:
* the command is internal, so the public port serves no route of it, and a client cannot confirm a payment the gateway
* never charged.
*/
@InternalCommand
@Summary("Confirm a payment")
data class ConfirmPayment(
val transactionId: String,
)内部命令只由平台发送:本服务的 Saga 与定时器、进程内的 CommandClient,以及别的服务经内部端口的调用;后者与任何命令一样,受 wow.callers.<service>.commands 的许可约束(调用其他服务)。Manifest 把它记为 "internal": true,路由与同类命令一样照写,内部端口按相同路径受理。公开端口不挂这些路由:在公开端口请求 POST /eshop-payment/payment/{id}/confirm-payment 答 404,与任何未知路径的 problem 相同,不透露这条命令存在。公开端口的 OpenAPI 文档与 wow-generator 生成的客户端都不含这条命令,也不含只有它用到的类型。内部端口受理的每次调用仍由命令授权器裁决。
404 让探测分不清内部命令与未知路径;契约本身仍是公开的。公开端口的 GET /wow/manifest 与列出其他命令一样列出它,并标注 "internal": true:按名字调用服务的工具从那里读取契约,Saga 的 derives 与等待链也指向这条命令。不想公开契约的部署,用 wow.http.manifest.enabled: false 关闭这个端点。
eShop 的 ConfirmPayment 与 FailPayment 是内部命令:网关 Saga 用它们记录网关的答复;买家若能发送 ConfirmPayment,就能不付款而完成支付。把公开命令改为内部会删去它的公开路由,兼容检查把它报告为破坏;把内部命令改为公开则增加路由,是加法(Manifest 与兼容性)。系统命令(包括生命周期命令)从不是内部命令。
编写根
根是一个标注了 @AggregateRoot、由它的状态构造的类。它的命令函数做出决定:每个函数接收一条命令,返回它的决定。
fun onCommand(command: SubmitOrder): OrderSubmitted = OrderSubmitted(command.items)编译器找出每个标注 @OnCommand 或名为 onCommand 的非私有函数。第一个参数是命令;返回值是决定:
| 返回 | 含义 |
|---|---|
Decision.events(e1, e2, …) | 这些事件,作为一条事件流一起追加 |
Decision.NoOp | 命令被接受但什么都不改变;不写入任何东西 |
Decision.reject(code, message) | 命令被拒绝;不写入任何东西,调用方拿到错误码(拒绝与错误码) |
| 一个事件,或一个事件列表 | 这些事件 |
Unit、null 或空列表 | 什么都不改变 |
返回 Decision 的函数没有告诉编译器它能产生哪些事件,所以要在 @OnCommand(produces = [...]) 里列出它们。Manifest 会发布这份列表。返回类型本身已经指明事件的函数,例如返回某个事件类或它们的密封类型,不必重复列出。
返回 Decision(或 Any、类型参数)且没有列出 produces 的函数可以编译,但会得到一条警告:它的事件不在 Manifest 中,所以内核无法拒绝它本不该产生的事件,客户端和对端也不知道它会提交什么。把这些事件列在 produces 里。只拒绝或什么都不改变的函数没有事件可列;在它上面写 @Suppress("UnlistedEvents") 表明这一点并消除警告。从库中继承的命令函数,在继承它的模块里不会被警告:由它的作者列出它产生的事件。
返回类型是接口或抽象类、又不是密封的,会导致编译错误。这样的函数返回的是几个实现中的一个,而这个类型说不出是哪几个。改为把它们列出来:把这个类型改成以这些事件为子类的密封接口,直接返回事件类本身,或者返回 Decision 并写上 produces。元素类型(List<OrderEvent>)和密封类型的子类也适用同样的规则。
聚合以根类名的 snake case 命名(Order 是 order,ViewPreferences 是 view_preferences),或者用 @Name 指定。
读取准入后的命令
需要准入从请求中得知的信息的命令函数,接收 Command<X> 而不是 X:
| 成员 | 包含 |
|---|---|
body | 命令 X |
ref | 聚合:它的上下文、名字、租户和 ID |
scope | 调用方的所有者和空间,按聚合的所有权保留:租户级聚合(Ownership.TENANT)看不到所有者;ID 即所有者 ID 的聚合,对派生的命令或没有所有者的命令,以自己的 ID 作为所有者 |
headers | 宿主保留的请求事实(wow.http.request-facts),例如调用的应用 |
causation、operator | 命令所属的链,以及它以谁的名义记录 |
记录提交来源应用的反馈表单读取请求事实:
fun onCommand(command: Command<SubmitFeedback>): FeedbackSubmitted =
FeedbackSubmitted(command.body.text, app = command.headers["app_id"])函数不必检查调用方能否接触这个聚合:内核根据作用域或聚合的访问策略检查它(内核检查什么)。每个所有者只有一个的聚合,例如购物车,以所有者的 ID 为 ID(每个所有者一个聚合)。
使用领域服务
命令之后的参数是领域服务,或者 DecideScope。服务会成为生成的模型的一个参数,宿主在构建模型时绑定一次:取应用中该类型唯一的 Bean,或者它的 primary Bean。在参数上写 @Name("…") 则取这个名字的 Bean,于是同一类型的两个参数可以取两个不同的 Bean。
订单把它的支付策略作为服务接收,并在库存确认后用 DecideScope 为自己定时取消:
/**
* Confirms one product. A confirmation the order does not wait for changes nothing: one read again from the
* stock stream, or one that arrives after another product's shortage cancelled the order (that reservation is
* released when the inventory hears of the cancellation).
*/
// Decision: the last confirmation produces two events, the item's and the order's.
@OnCommand(
produces = [OrderStockItemConfirmed::class, OrderStockConfirmed::class],
schedules = [CancelOrder::class],
)
fun onCommand(command: ConfirmOrderStock, paymentPolicy: PaymentPolicy, scope: DecideScope): Decision {
val waiting = state.status == OrderStatus.AWAITING_VALIDATION &&
command.productId in state.products() &&
command.productId !in state.confirmedProducts
if (!waiting) {
return Decision.NoOp
}
val confirmed = OrderStockItemConfirmed(command.productId)
if (state.confirmedProducts + command.productId != state.products()) {
return Decision.events(confirmed)
}
scope.schedule(
PAYMENT_GRACE_PERIOD,
ScheduleRule.after(paymentPolicy.gracePeriod),
CancelOrder(PAYMENT_TIMEOUT)
)
return Decision.events(confirmed, OrderStockConfirmed)
}服务可以做 I/O。会阻塞线程的函数,例如调用 JDBC 的函数,要标注 @Blocking,它会在虚拟线程上运行。函数也可以是 suspend,或者返回 Reactor 的 Mono,这需要类路径上有 wow-reactor。
查询读取端
命令函数需要聚合状态以外的信息时,例如它引用的账户是否存在,就把节点的读取 API 作为服务接收:QueryApi,或者用于受信读取的 NodeQueryApi。两者都在 wow-api(me.ahoo.wow.api.read)里,领域模块不需要别的依赖,宿主会把节点自己的读取 API 交给模型。如果调用方已经知道这个事实,就把它放进命令(用 CommandTransformer,或由发送命令的 Saga 放入):读取会在每次版本冲突后的重试中再跑一遍,而且读到的内容可能在事件追加之前就变了。
以命令的调用方身份读取,调用方从命令重建:它的租户、所有者和空间,以及它的操作员。读取能看到的,就是这个调用方能看到的:
@OnCommand
suspend fun onCommand(command: LendBook, queries: QueryApi, scope: DecideScope): BookLent {
val admitted = scope.command
val caller = Caller.of(admitted.ref.tenantId, admitted.scope, admitted.operator?.id)
queries.of(caller).snapshots<AccountState>().state(command.accountId)
?: reject("library.book.no-account", "No account ${command.accountId}.")
return BookLent(command.accountId)
}重建的调用方没有角色、策略或声明。它们只随请求存在,而命令可能在另一个节点上决定,或者从它的命令主题取来。读取这些属性的读取授权器什么也看不到,要求某个角色的授权器会拒绝这次读取。
不应受任何策略限制的读取,接收 NodeQueryApi 并用 trusted() 读取。
受信读取跨越租户
trusted() 读取所有租户。在有多个租户的服务里,函数要自己按命令的租户过滤,如示例所示。
@OnCommand
suspend fun onCommand(command: LendBook, queries: NodeQueryApi, scope: DecideScope): BookLent {
val lent = queries.trusted().snapshots<BookState>().count(
countQuery {
filter {
tenantId(scope.command.ref.tenantId)
"state.account" eq command.accountId
}
},
)
if (lent.count >= MAX_BOOKS) {
reject("library.book.too-many", "Account ${command.accountId} has $MAX_BOOKS books.")
}
return BookLent(command.accountId)
}读取端跟随日志。 快照在每次提交之后写入,而不是随提交一起写入,所以命令函数或 Saga 里的查询,可能看不到片刻之前的写入,比如某个 Saga 刚刚改过的聚合。要为此而设计:某行缺失或过期会让命令被拒绝时,让调用方重试这种拒绝,或者由发送方在命令里带上检查所需的事实。函数收到的聚合自身状态总是最新的。
读取失败时,命令以 wow.command.failed(500)失败,与任何意外异常一样。读取端拒绝的读取,以及代码写错的读取(例如没有聚合使用的状态类),都是如此:过错在代码,不在调用方。在聚合规格里,把 memoryQueries { snapshots(...) } 作为 QueryApi 交给模型工厂(测试)。
向内核要更多东西
DecideScope 是一次决定除了事件之外还能向内核要的东西:
| 成员 | 作用 |
|---|---|
command | 正在决定的命令,类型为 Command<*> |
now | 这次决定在节点时钟上的时间(纪元毫秒):它的事件被记录的提交时间。用它而不是系统时钟计算截止时间或过期时间,测试就能设定它(测试) |
ids | 节点的 ID 生成器,用于决定需要的 ID |
putResult(key, value) | 往事件追加之后给调用方的回答里加一个值。它不存储:重复的请求不会再得到它 |
reject(code, message) | 用一个错误码拒绝命令,可以附带用于 problem 的 extensions(拒绝) |
reserve(namespace, key) | 为这个聚合预留一个唯一键,例如用户名;被另一个聚合持有时返回 false。ReservationTenancy 参数扩大它的范围(唯一键) |
release(namespace, key) | 在事件追加之后,释放这个聚合持有的一个键;使用预留它时的 ReservationTenancy |
onAppendFailure { … } | 在确定事件没有追加时,撤销一个外部动作(见下文) |
afterAppend { … } | 事件追加之后运行;失败只记日志,不抛出(见下文) |
schedule(key, rule, command, target) | 定时一条命令,发往这个聚合,除非 target 指定了另一个。它与事件一起写入,要么都写入,要么都不写 |
cancelSchedule(key) | 取消这个聚合在 key 下保持的定时 |
决定可以通过 putResult 把它算出的值(比如一个报价)回答给调用方;调用方从结果的 results 里读取,这里是 {"cost": 4.50}:
fun onCommand(command: QuoteShipping, scope: DecideScope): ShippingQuoted {
val cost = rates.costOf(state.weight, command.destination)
scope.putResult("cost", cost)
return ShippingQuoted(command.destination, cost)
}命令创建的东西最好按它的聚合 ID 查找,重复的请求也会再次得到它。eShop 的结账不回答订单 ID:订单由 ordering 编号(SO-261031-42),等到订单提交(committed(eshop-ordering))的结账,从这一跳的聚合 ID 读到订单号(Saga)。
定时命令的函数要在 @OnCommand(schedules = [...]) 里声明命令的类,就像订单对 CancelOrder::class 做的那样:编译器读不到函数体里的调用,而 Manifest 按完整类型名列出它们。
撤销对外部的操作
决策可能在事件追加之前就对聚合之外做了事:预授权一张卡,在另一个系统里占一个座位。onAppendFailure 登记怎样撤销这件事,内核在确知事件没有追加时执行它:
@OnCommand
suspend fun onCommand(command: PayOrder, scope: DecideScope): Decision {
val authorization = payments.authorize(state.totalAmount, key = scope.command.requestId)
scope.onAppendFailure { payments.void(authorization) }
return Decision.events(OrderPaid(authorization.id))
}以下情况会执行补偿:
- 决策拒绝了命令(三种方式中的任意一种),或者什么也不追加(
Decision.NoOp); - 决策函数抛出异常;
- 追加遇到版本冲突。每次尝试先撤销自己做的事,命令再基于更新后的状态重新决定;
- 存储拒绝了追加,或者以确定没有写入的方式失败;
- 追加时发现这个请求已经由另一次尝试提交。
追加结果未知时不执行。内核会先读取这次追加本应占用的版本;只有仍然无法判断时,才回答 wow.command.unknown,此时事件可能已经提交。进程崩溃时也无法执行。所以,命令丢失后不能留下的外部操作,还需要自己的清理手段:外部系统里的过期机制,或者读取日志的对账。
- 顺序。 补偿按登记的逆序逐个执行,每个都会等待完成,调用方已经离开时也一样。没有超时:卡住的补偿会挡住这个聚合的命令,所以要给它的调用设上限。
- 失败。 一个补偿失败不会阻止其余的补偿。失败会被记日志,或附加到它所撤销的那个失败上,命令的应答不变。
- 两次。 基于已落后于日志的缓存状态被拒绝、或未作改变的命令会重新决定,第一次尝试先被补偿。外部操作因此可能做了两次、撤销一次,所以要以命令为键,如上例中的
requestId。 - 预留的键。
reserve会登记自己的补偿:事件没有追加时,放弃这次尝试的持有,而这个聚合原本就持有的键继续持有。一次尝试预留和释放的键随它追加的事件一起记录,所以结果未知、或进程在追加之后崩溃时,内置的wow.reservation订阅按日志确认和释放它们。只有没有任何已追加条目记录的持有(进程在追加之前崩溃)才会过期,30 秒后由这个键的下一次预留取得。
afterAppend 是另一面:事件追加之后、应答调用方之前,按登记顺序执行。重复请求不执行,任何没有追加的结果也不执行,拒绝也不执行,即使是提交了事件的拒绝:那时执行的是补偿。失败只记日志,事件仍然已提交。订阅可能在它结束之前就看到这次提交;进程在两者之间崩溃,它就被跳过。所以它适合可以丢失的工作,例如预热缓存。必须跟随每次提交的工作应放在反应器或 Saga 中,由它们读取已提交的事件。
编写状态
状态类为每个事件提供一个演化函数,标注 @OnSourcing 或名为 onSourcing。演化函数应用一个事件:它不接收服务,也不做 I/O。
fun onSourcing(event: OrderSubmitted) {
items = event.items
totalAmount = event.items.fold(BigDecimal.ZERO) { total, item ->
total + item.unitPrice.multiply(BigDecimal(item.quantity))
}
status = OrderStatus.SUBMITTED
}- 聚合能产生的每个事件都需要一个演化函数,即使它什么都不改变。
- 原地修改状态的演化函数不返回任何东西。返回状态类型的演化函数则返回新的状态,用于不可变状态。
- 在第一个事件之前就给每个属性一个值。内核按状态的原样序列化它,用于快照和发布的事件流。
- 没有 setter 的
val属性,从快照或查询返回的状态读回时会写入它的字段:状态边执行边填充的集合或映射(val items = sortedSetOf<String>()、MutableList或MutableMap)和构造时设定的值都是如此。计算属性(背后没有字段的 getter)只写出,不读回。所以状态填充的集合要放在这样的val中,而不是放在私有字段上、由自己的 getter 暴露(val items: List<Item> get() = lines):后者读不回,内核会回放聚合的日志,而不是加载它的快照。 - 状态及其事件不持有 Wow 查询协议的类型(
me.ahoo.wow.api.query:过滤条件、排序、投影、字段)。它们是随 Wow 演进的传输类型,构建会拒绝持有它们的状态或事件,并给出路径:改持应用自己的类型(例如字段名列表),在执行查询时再据此构建查询。命令或查询可以自由持有它们。 - 命令函数从不修改状态,只读取它。测试 DSL 比较每次决定前后的状态,若有改变就让测试失败。
- 标注
@Access的成员是聚合的访问属性之一,聚合的访问策略用调用方的声明比较它(声明访问属性)。
构造初始状态
生成的模型在聚合的第一个事件之前,用状态类的构造函数构建它的状态:取主构造函数,Java 类取第一个构造函数。编译器接受四种形状,其他一律拒绝:
| 构造函数 | 状态怎样构建 |
|---|---|
() | 不带参数。推荐这种:聚合的 id 与租户在它的 ref 和事件流里,不在状态里 |
| 参数全部有默认值 | 不带参数:每个参数保留它的默认值 |
(id: String),没有默认值 | 带上聚合的 id |
(id: String, tenantId: String),没有默认值 | 带上聚合的 id 与它的租户 |
- id 与租户按位置传入,与参数叫什么名字无关。
- 有默认值的参数收不到 id:
(id: String = "")是参数全部有默认值的构造函数,状态从""开始。要让状态收到 id,就不要给id默认值。只有领域离开 id 无法工作时才接收它。 - 自身就是状态、旁边没有状态类的聚合根,也这样构建。
读取提交
演化函数可以在事件之后再接收该事件所属的提交:一个 EventStream,带有日志记录的提交时间、操作人、版本和命令的 ID。内核在决定时和重放时(从日志,或从快照及其后的事件流)给出同一个提交,所以之后重建的状态是同一个状态。要保存时间的状态从提交读取它,绝不从时钟读取:
fun onSourcing(event: OrderPaid, commit: EventStream) {
paidAt = commit.commitTime
paidBy = commit.operator?.id
}- 不可变状态以同样的方式接收它:
fun onSourcing(event: OrderPaid, commit: EventStream): OrderState。 - 以代码编写模型时,写作
evolve<OrderPaid> { event, commit -> … }或evolveTo<OrderPaid> { state, event, commit -> … }。 - 提交时间就是这次决定的
scope.now:决定据此计算的截止时间与状态读到的时间一致。 - 从 9.x 导入的历史把每条流的创建时间和操作人保留为其提交的提交时间和操作人(迁移)。
在每条命令之后运行代码
标注 @AfterCommand 或名为 afterCommand 的函数,在它适用的命令函数之后运行:include 里的命令函数,include 为空时是全部,再减去 exclude 里的。它看到的是命令函数看到的那个状态,返回事件,这些事件在同一条流里追加在命令自己的事件之后。命令被拒绝时它不运行,它也不能拒绝。它失败时整条命令失败,什么都不写。
生命周期命令
每个聚合还处理 me.ahoo.wow.api.lifecycle 中的四条系统命令:DeleteAggregate、RestoreAggregate、TransferOwner 和 TransferSpace。它们改变的是聚合的元数据,而不是它的状态;已删除的聚合拒绝除 RestoreAggregate 和函数标有 @OnCommand(onDeleted = true) 的命令之外的每条命令。@AggregateRoot(lifecycle = [...]) 可以收窄这份列表;空列表则一条都不启用。
测试
用 aggregateSpec 测试每个命令函数:给定这些事件,执行这条命令,期望得到这些事件和这个状态。做法见测试领域代码。