决定与演化
Wow 的领域模型由普通的类和函数组成。本页说明有哪几类函数、每类能做什么不能做什么,以及 Wow 怎样调用它们。这里讲的是模型,不逐个介绍注解。
五类函数
| 函数 | 输入 | 返回 | 能否做 I/O | 失败时 |
|---|---|---|---|---|
| 决定 | 当前状态和一条命令 | 一个决定结果:事件、无变化或拒绝 | 能,通过构建模型时绑定的依赖 | 命令被拒绝或失败,什么都不写。 |
| 演化 | 状态和一个事件 | 无(它修改状态),或一个新状态 | 不能 | 这是模型的缺陷,立即失败。 |
| 投影 | 一次已提交的变更及其后的状态 | 无 | 能;它自己保证写入幂等 | 在这个投影里停放该聚合。 |
| 反应 | 一次已提交的变更及其后的状态 | 无 | 能;它自己保证效果幂等 | 在这个反应器里停放该聚合。 |
| Saga | 一次已提交的变更及其后的状态 | 要发送的命令 | 能 | 停放该聚合,除非 Saga 声明了它收到的这种拒绝在预期之内。 |
决定与演化属于聚合;投影、反应和 Saga 函数属于处理器,处理器订阅日志。它们怎样运行,见订阅、失败与重放。
先决定,再演化
决定与演化是两个函数,因为它们回答的是不同的问题。
- 决定回答“按现在的事实,会发生什么?”它读状态、检查规则,可以调用领域服务,产出事件。业务规则只放在这里。
- 演化回答“既然发生了这件事,现在的事实是什么?”它把一个事件应用到状态上。事件永久保存,每次重建状态都要重放,所以演化看到同样的事件必须每次得出同样的结果。因此它不做 I/O,也没有依赖。
这种分离带来几条可以依赖的结论:重放日志能精确重建聚合当时的状态;冲突重试只在新状态上重新运行决定,别的什么都不重复;测试用事件和状态来写期望,与写模型时用的是同一套语言。
决定结果有三种,三种都是值,而不是异常:
- 事件:按顺序追加的一个或多个事件。
- 无变化:命令被接受,不追加任何东西。
- 拒绝:带错误码、消息和可选字段 violations 的拒绝。错误码在模型上声明并列进 Manifest,所以客户端和其他服务事先就知道它。
聚合上没有错误处理函数。拒绝就是决定函数里返回拒绝的那个分支;失败之后的补偿动作属于 Saga。
决定能触及什么
决定可以做 I/O,但只能以内核能掌控的方式接触外界:
- 领域服务是命令函数的参数。编译器把它们变成生成的模型工厂的参数,宿主在构建模型时绑定一次,不会每条命令都去查找。
- 决定作用域是这条命令自己的上下文。通过它,决定可以跨聚合预留唯一键、登记一个在追加没有成功时执行的补偿、为以后定时一条命令,以及给调用方返回额外的结果。
- 命令可以只是命令体,也可以是准入后的命令;后者还带着准入确定的内容:它寻址的聚合、调用方的作用域和请求事实。
身份从不来自命令体。命令寻址的聚合在准入时由路由或消息决定,所有者和空间来自已认证的调用方。原因见身份、租户与访问。
after 钩子
after 钩子在命令函数之后、在同一个状态上运行,可以往同一个决定结果里追加事件。它是决定的一部分:它的事件和命令函数的事件一起演化、一起追加;它失败时整条命令失败,什么都不写。一组命令都要追加的事件,适合用它。
生命周期命令
每个聚合不用写代码就能处理几条生命周期命令:删除、恢复、转移所有者、转移空间和打标签。它们的决定与演化由内核提供,改的是聚合的元数据,元数据随快照一起保存。聚合默认处理全部这些命令,除非它只列出其中一部分。设置和取消定时,也同样是每个聚合都有的系统命令。
可变或不可变的状态
演化函数有两种写法:
- 可变(默认):演化函数拿到状态,修改它的字段;嵌套的集合原地更新。
- 不可变:演化函数拿到状态和事件,返回一个新状态。
两种写法都安全,因为不可变通常提供的保证,在这里由内核而不是状态类提供:
| 保证 | 状态可变时内核怎样做到 |
|---|---|
| 失败不会留下改了一半的状态 | 每次尝试都从缓存里取出状态,只有这次尝试提交后才放回。失败的尝试丢弃这个实例,下一条命令重新加载状态。 |
| 不会有两条命令同时修改一个状态 | 每个聚合有一条车道:它的命令按到达顺序逐条执行。 |
| 每个版本都有稳定的视图 | 版本提交时,内核把状态序列化一次。快照和订阅拿到的是这份视图,而不是活的实例。 |
可变状态的代价是:没有什么能阻止决定函数直接改字段。这样的改动不经过事件,内存里的状态会与日志渐渐不一致,直到下次重新加载。只在演化函数里改状态;命令函数或 after 钩子修改了状态的测试,测试 DSL 会让它失败。
两种写法
同一个模型可以在类上用注解写,也可以用 Kotlin 的 Decider DSL 写。两者编译成同一个聚合模型:一张命令函数表、一张演化函数表、身份规则、创建语义和守卫。用注解时,编译器在构建期生成绑定代码;运行时不扫描,也不经反射调用。
Java 代码使用同样的注解,编译器既读 Kotlin 源码也读 Java 源码。
函数怎样被调用
很多函数只是纯计算:只凭状态就能决定的命令、把字段复制到内存的投影。它们不应该为协程付出代价。所以编译器按签名为每个函数选定三种调用形态之一,运行时不再逐次判断:
| 形态 | 识别依据 | 调用方式 |
|---|---|---|
| 普通 | 普通函数 | 在聚合的车道上内联调用,不挂起,也不切换线程 |
| 挂起 | suspend | 在当前协程里调用;只有真正挂起时才让出线程 |
| Reactor | 返回 Mono、Flux 或 Publisher | 经 Reactor 适配模块调用 |
普通函数被假定为不阻塞。做阻塞 I/O 的函数,例如用 JDBC 写入的投影,要标上 @Blocking,运行时改在虚拟线程上执行它。阻塞而没有标记的函数,会拖住同一线程上的所有其他聚合。
内核本身只在真正的 I/O 处挂起。状态已在缓存里、决定函数又是普通函数时,一条命令从车道到追加是一段直线代码,追加是它唯一要等待的地方。
事件随时间变化
日志永久保存,而事件类会变。所以读取旧事件是模型的正常部分:
- 事件的身份是它的名字
context.aggregate.EventName,而不是它的类。重命名或移动类不影响历史;旧名字作为别名仍然可读。 - 事件声明一个修订号。结构变化时,你写一个从上一个修订号到下一个修订号的升级函数,编译器检查从修订号 1 到当前修订号的链条没有缺口。
- 已经没有类的事件类型,在它的聚合上声明为退役。读取时跳过它:重放忽略它,订阅越过它。
这些是你的模型的变化,不是 Wow 格式的变化。Wow 自己的存储格式或线上格式只在大版本之间变化,由迁移层处理。