线上协议
本页列出 Wow 写入存储、写入 Kafka 以及在节点之间传递的结构:每个字段的含义,以及这些结构在版本之间怎样变化。当你从一个不是 Wow 服务的系统消费发布的事件流、自己解码命令结果,或者需要知道一次升级可能改变什么时,读这一页。
JSON 示例由 Wow 自己写出:一个测试对每个示例编码,线上形式一变它就失败。
原则
- 事实、视图和传输段分开保存。 事实,即一次提交的事件流,只写一次,永不修改。视图,例如状态或快照,从事实派生,可以重建。传输段,例如一个未结束的等待或追踪上下文,只为投递服务:领域代码和查询永远看不到它,迁移和重放会丢弃它。
- 用名字,不用类。 命令、事件和错误码由模型的稳定名字标识。类可以重命名或移动,不影响历史。
- 线上用 JSON。 载荷是 JSON,它们的 schema 在 Manifest 里。数字保持精确的值和小数位:
2.50仍是2.50。 - 每个结构都有格式版本。 见格式版本。
名字
| 名字 | 形式 | 例子 |
|---|---|---|
| 聚合类型 | context.aggregate | bank.account |
| 聚合引用(文本形式) | context.aggregate/tenant/id | bank.account/t1/alice |
| 命令类型 | context.aggregate.Command | bank.account.Deposit |
| 事件类型 | context.aggregate.Event | bank.account.Deposited |
| 错误码 | 框架的是 wow.*;聚合自己的以其类型为前缀 | wow.wait.timeout、bank.account.insufficient-balance |
| 提交 ID | <aggregate reference>@<version> | bank.account/t1/alice@2 |
命令类型默认是命令类的简单名,事件类型默认是事件类的简单名;@Name 可以覆盖两者。
常用值
这些值出现在大多数结构里。
| 值 | JSON | 说明 |
|---|---|---|
| 聚合引用 | {"type": {"context", "name"}, "tenantId", "id"} | 把 ID 留给服务端生成的创建命令,其 id 为 (none)。 |
| 作用域 | {"ownerId", "spaceId"} | 两者都可以为 null。在命令上是调用方的作用域;在提交或快照上是提交后聚合的作用域。 |
| 因果 | {"correlationId", "causationId"} | correlationId 是根命令的 ID;causationId 是直接原因,对 Saga 的命令是它处理的那次提交。调用方直接发出的命令没有因果,它自己就是根。 |
| 操作人 | {"id", "tenantId", "crossScope"} | 提交以谁的名义记录:调用方的主体标识和它自己的租户;它在另一个租户或作用域中行事时 crossScope 为 true。 |
| 跳 | {"stage", "context", "subscription"} | stage 是 ACCEPTED、COMMITTED 或 HANDLED;只有 HANDLED 才有 subscription;snapshot 指快照订阅。 |
时间都是纪元毫秒。
事件流
事件流就是事实:一条命令产生、由一次追加提交的事件。它是日志的一个条目,存储后端把它的字段排成列或文档字段。
| 字段 | 含义 |
|---|---|
ref | 聚合。 |
version | 这次提交之后聚合的版本:第一次提交为 1,之后每次加一。 |
commandId、requestId | 产生它的命令,以及该命令的幂等键。 |
commandType | 那条命令的类型名。请求 ID 用于另一种命令类型时被拒绝;用于同一类型时视为重试。 |
commitTime | 提交的时间。 |
events | 每个事件的 name、revision 和 payload,按顺序。设置或取消的定时也在其中,即系统事件 wow.ScheduleSet 和 wow.ScheduleCancelled。 |
scope | 这次提交之后聚合的所有者和空间。 |
tags | 这次提交之后聚合的标签。 |
causation | 命令所属的链,如果它是 Saga 发出的。 |
headers | 准入记录的请求事实,例如 app_id。 |
operator | 提交以谁的名义记录;匿名调用方为 null。 |
在事实之外,日志条目还有一个位置,即它在分区内的序号,并且可能带有传输段:未结束的等待的等待上下文、要确认的预留、追踪上下文。
状态事件
状态事件是一次提交,连同提交之后聚合的状态。处理函数在进程内收到它,发布的事件流在 Kafka 上承载它。它的字段是事件流的字段,再加上:
| 字段 | 含义 |
|---|---|
state | {version, schemaFingerprint, state}:这个版本提交时序列化一次的状态,以及它的 schema 指纹。聚合发布时不带状态则为 null。 |
deleted | 这次提交之后聚合是否已删除。 |
事件在发布前升级到当前修订号。
Kafka 信封
Wow 写入 Kafka 的每条消息都是一个信封。
| 部分 | 内容 |
|---|---|
| 键 | 聚合引用的文本形式,所以一个聚合的消息留在同一个 Kafka 分区里,并保持顺序。 |
| 头 | wow-format、wow-kind 和 wow-type:信封的格式、种类和类型,不解析值就能读到。 |
| 值 | {format, kind, id, type, payload, transport}。 |
kind | id | type | payload | 主题 |
|---|---|---|---|---|
state-event | 提交 ID ref@version | 聚合类型 | 一个状态事件 | wow.<context>.<aggregate>.state,除非聚合另行指定 |
command | 命令 ID | 命令类型 | 一条命令 | wow.<context>.commands |
command-receipt | 命令 ID | 命令类型 | 一张回执 | 发送方上下文的 wow.<context>.receipts |
消费者按 id 去重。transport 为 null,或者只包含 wait 上下文和 trace 传播字段。
一个发布的状态事件:
{
"format": 1,
"kind": "state-event",
"id": "bank.account/t1/alice@2",
"type": "bank.account",
"payload": {
"ref": {
"type": {
"context": "bank",
"name": "account"
},
"tenantId": "t1",
"id": "alice"
},
"version": 2,
"commandId": "c-2",
"requestId": "r-2",
"commandType": "bank.account.Deposit",
"commitTime": 1760950800000,
"events": [
{
"name": "bank.account.Deposited",
"revision": 1,
"payload": {
"amount": 100
}
}
],
"state": {
"version": 2,
"schemaFingerprint": "43bfa906ccc015f9dc85b5ad142a24dcb99668d69f98ebd9d1ae73471c97be4d",
"state": {
"owner": "Alice",
"balance": 100
}
},
"scope": {
"ownerId": "u-42",
"spaceId": null
},
"deleted": false,
"tags": {},
"causation": null,
"headers": {
"app_id": "teller-app"
},
"operator": {
"id": "u-42",
"tenantId": "t1",
"crossScope": false
}
},
"transport": null
}事件载荷和状态是内嵌的 JSON 对象,而不是转义的字符串,所以消费者可以按路径读取值。
主题上的命令
发往上下文命令主题的命令,和在服务节点之间转发的命令,线上形式相同:
| 字段 | 含义 |
|---|---|
type | 命令类型;接收方按它、依据自己的 Manifest 解码 body。 |
id、requestId | 命令的 ID 和幂等键。Saga 的命令的请求 ID 由它处理的那次提交推出。 |
ref | 它寻址的聚合。 |
body | 命令的业务数据。 |
expectedVersion | 发送方期望聚合所处的版本,或 null。 |
scope、operator、headers、causation | 准入为根命令确定的值;Saga 的命令继承它们。 |
replyTo | 只出现在 Saga 发往另一个上下文的命令上:{context, subscription, ref, version, committed},即要为之发回执的那次 Saga 提交;committed 要求成功时也发回执。 |
主题的分区就是目标上下文的日志分配给 ref 的那个分区,所以主题的分区数与那份日志相同。等待计划不随命令传递;未结束的等待的等待上下文放在 transport 里:
{
"format": 1,
"kind": "command",
"id": "c-3",
"type": "bank.account.Withdraw",
"payload": {
"type": "bank.account.Withdraw",
"id": "c-3",
"requestId": "tr-9@1#0",
"ref": {
"type": {
"context": "bank",
"name": "account"
},
"tenantId": "t1",
"id": "alice"
},
"body": {
"amount": 30
},
"expectedVersion": null,
"scope": {
"ownerId": null,
"spaceId": null
},
"headers": {},
"causation": {
"correlationId": "c-1",
"causationId": "bank.transfer/t1/tr-9@1"
},
"operator": {
"id": "u-42",
"tenantId": "t1",
"crossScope": false
},
"replyTo": {
"context": "bank",
"subscription": "TransferSaga.onRequested",
"ref": {
"type": {
"context": "bank",
"name": "transfer"
},
"tenantId": "t1",
"id": "tr-9"
},
"version": 1,
"committed": false
}
},
"transport": {
"wait": {
"waitId": "w-7",
"origin": {
"node": "bank-1",
"address": "10.0.0.5:9090",
"service": "bank-service"
},
"remaining": [
{
"stage": "HANDLED",
"context": "bank",
"subscription": "snapshot"
}
],
"deadline": 1760950810000
},
"trace": {}
}
}等待上下文写明命令来自的节点(origin,带它的内部地址)、还要到达的跳,以及绝对截止时间。
回执
跨上下文命令的目标在确认这条命令之前,把回执写进发送方上下文的回执主题。回执以 Saga 处理的那次提交的引用为键,所以一个 Saga 聚合的回执保持顺序;回执的 id 就是命令的 ID。
| 回执 | 载荷 |
|---|---|
| 拒绝 | {commandId, commandType, ref, error, causation, executionId, operator, scope, headers},不含命令体 |
| 成功(应要求时) | {commandId, commandType, ref, version, result, causation, executionId, operator, scope, headers},其中 result 是 COMMITTED 那一跳的命令结果,不含状态 |
executionId 是 {subscription, ref, version}:命令所来自的那次 Saga 处理函数调用。headers 只包含会传播的请求事实。
{
"format": 1,
"kind": "command-receipt",
"id": "c-3",
"type": "bank.account.Withdraw",
"payload": {
"commandId": "c-3",
"commandType": "bank.account.Withdraw",
"ref": {
"type": {
"context": "bank",
"name": "account"
},
"tenantId": "t1",
"id": "alice"
},
"error": {
"code": "bank.account.insufficient-balance",
"message": "The balance is 20.",
"violations": [],
"extensions": {}
},
"causation": {
"correlationId": "c-1",
"causationId": "bank.transfer/t1/tr-9@1"
},
"executionId": {
"subscription": "TransferSaga.onRequested",
"ref": {
"type": {
"context": "bank",
"name": "transfer"
},
"tenantId": "t1",
"id": "tr-9"
},
"version": 1
},
"operator": {
"id": "u-42",
"tenantId": "t1",
"crossScope": false
},
"scope": {
"ownerId": "u-42",
"spaceId": null
},
"headers": {}
},
"transport": null
}命令结果
在内核之外,命令的结果到处都用同一个结构报告:HTTP 响应、服务端推送事件流的每个事件、等待信号、回执和客户端。
| 字段 | 含义 |
|---|---|
format | 结构的格式版本。 |
commandId、requestId、correlationId | 命令、它的幂等键,以及它所在链的根。 |
ref | 聚合。 |
hop | 这个结果报告的是等待计划的哪一跳。 |
function | 由谁产生:{kind, context, processor, name}。kind 是 COMMAND、PROJECT、REACT、SAGA、SNAPSHOT、RESERVATION 或 TIMERS;对命令,processor 和 name 是聚合和命令;对订阅,是它的处理器和函数。 |
status | SUCCEEDED、REJECTED 或 FAILED。 |
version | 提交之后聚合的版本;什么都没追加时不变。 |
duplicate | 这个请求已经提交过,没有再次决定。 |
error | 用于 REJECTED 和 FAILED:{code, message, violations, extensions},每个 violation 是 {path, code, message}。 |
results | 决定函数返回的额外结果;只在第一次提交时出现,重复时绝不出现。 |
derived | 用于 Saga 那一跳:它为这次提交发出的命令的 ID。 |
signalTime | 结果产生的时间。 |
transformedFrom | 准入时转换器改写了命令时:原命令的 {command, aggregateId}。 |
chainEnded、pending | 因为 Saga 没有再发命令,等待在最后一跳之前结束:chainEnded 为 true,pending 按顺序列出没有到达的跳。 |
内核的结局在 COMMITTED 上怎样映射到结果:
| 结局 | status | 说明 |
|---|---|---|
| 已提交 | SUCCEEDED | version 是新版本;results 即决定函数返回的结果 |
| 早已提交 | SUCCEEDED | duplicate 为 true;没有 results |
| 无变化 | SUCCEEDED | version 不变 |
| 被拒绝 | REJECTED | error.code 是拒绝的错误码 |
| 争用 | FAILED | wow.command.contended;用同一请求 ID 重试 |
| 未知 | FAILED | wow.command.unknown;用同一请求 ID 重试,它不可能提交两次 |
| 等待超时 | FAILED | wow.wait.timeout;等待者不再等待,并不表示命令失败 |
在 HTTP 上,成功的命令以 200 返回结果。拒绝或失败返回 application/problem+json(RFC 9457):type 设为 urn:wow:error:<code>,扩展成员 code 和 violations,整个结果放在 result 下,所以客户端无论哪种情况都只解析一种形状。带 Accept: text/event-stream 时,每一跳的结果是一个事件,最后一跳之后关闭流。
等待信号
完成的那一跳直接向命令来自的节点的内部端口发送信号。信号带着这一跳的结果:
{
"format": 1,
"waitId": "w-7",
"result": {
"format": 1,
"commandId": "c-2",
"requestId": "r-2",
"correlationId": "c-2",
"ref": {
"type": {
"context": "bank",
"name": "account"
},
"tenantId": "t1",
"id": "alice"
},
"hop": {
"stage": "HANDLED",
"context": "bank",
"subscription": "snapshot"
},
"function": {
"kind": "SNAPSHOT",
"context": "bank",
"processor": "snapshot",
"name": "snapshot"
},
"status": "SUCCEEDED",
"version": 2,
"duplicate": false,
"error": null,
"results": {},
"derived": [],
"signalTime": 1760950800042,
"transformedFrom": null,
"chainEnded": false,
"pending": []
}
}一个节点可以把发往同一节点的几个信号放在一个 JSON 数组里发送。接收节点按等待 ID、跳,以及发出信号的上下文和订阅来对信号去重。
集群通道
一个服务的节点在各自的内部端口上互相转发命令,Wow-Cluster-Token 请求头里带着服务的集群令牌。
| 消息 | 字段 |
|---|---|
| 转发 | {v, command, wait, deadline, trace}:线上形式的命令、未结束的等待的等待上下文、绝对截止时间和追踪传播字段。 |
| 回复 | {v, result, reached, refused}:要么是属主在 COMMITTED 上的 result,以及它当即 reached 的后续各跳;要么是 refused,取值为 not-owner、expired、unknown-type、too-large 之一。 |
被拒绝的转发由发送节点自己执行。已经发出、随后超时的转发则不会:发送方回答结果未知,调用方用同一个请求 ID 重试。
快照
快照是一个聚合的最新状态及其元数据,按快照存储保存的样子:
| 字段 | 含义 |
|---|---|
ref、version | 聚合,以及并入状态的最后一次提交的版本。 |
state | 序列化的状态。 |
schemaFingerprint | 状态 schema 的指纹,与状态视图的相同。 |
stateRevision | 写入快照时聚合的状态修订号。修订号较低的快照绝不替换修订号较高的快照。 |
scope、tags、deleted | 截至最后一次提交,聚合的所有者和空间、标签和删除标记。 |
firstEventTime、lastEventTime | 它第一次和最后一次提交的时间。 |
operator、firstOperator | 它最后一次和第一次提交的操作人。 |
schedules | 它保持的定时,每条规则按它的键。 |
这些也是 snapshot 查询模型的系统字段。
格式版本
带格式版本的结构有:日志条目的事实和传输段、快照记录、状态事件、命令的线上形式、命令结果、等待信号、Kafka 信封,以及集群通道的消息。
- 日志和快照存储按表记录格式版本。线上的每条消息都带着自己的格式版本:
format,在集群通道上是v。 - 增加不算格式变化。 新增一个带默认值的可选字段,格式版本不变。读者忽略不认识的字段,缺失的字段填上默认值。
- 在同一主版本内,新字段先由一个次版本开始读取,下一个次版本才开始写入,所以滚动升级期间相邻的两个次版本可以并存。
- 旧读者读不了的变化要等下一个主版本,由它的迁移工具转换数据。
读者拒绝 format 不认识的信封,也拒绝 kind 不认识的信封。