软件建模21:Event Modeling
客户在预留页面选择设备 E1、10 点开始、12 点结束,点击确认。结果页除了设备和租期,还显示客户编号、确认时刻和持续小时数。这七个字段从哪里取得?如果页面又增加“已收押金”,后端没有收款输入,也没有收款事实,字段就不能凭界面需要自动成立。
Event Modeling 可以把这个问题画成连续的信息路径,再用场景核对路径上的值。本篇给设备租赁增加一条可运行的预留流程,检查输入、命令、事件和读视图是否吻合。素材与业务规则均为教学构造,不代表真实参与者完成过工作坊。
一条路径需要哪些内容
Adam Dymitruk 在原始说明中以时间线组织信息,连接界面、命令、事件和视图,并要求追踪字段从输入到输出的变化。命令表达改变系统的意图,视图提供使用者需要的信息;场景可以围绕具体命令或视图展开。Event Modeling: What is it?,2019
本例的预留页面只有四个输入:预留编号、设备编号、开始和结束时刻。客户编号来自会话,确认时刻来自服务器时钟。为了结果可重复,实验把会话和时钟也固定在 JSON 中,分别放入 session 与 clock;它们是测试替身,未实现登录鉴权或可信时间服务。
用户操作 submit-reservation 被转换为命令。处理器检查编号、期间以及设备占用,成功后更新当前预留表,返回 ReservationConfirmed。投影函数读取这条事件,生成结果页。读视图只是七个字段的字典,没有前端框架,也没有页面截图冒充业务执行。
flowchart LR
UI["操作:提交预留<br/>R1 / E1 / 10至12"] --> C["预留命令"]
S["会话 C1<br/>时钟 9"] --> C
C --> H["检查期间与占用"]
H --> DB["当前预留表"]
H --> E["ReservationConfirmed"]
E --> V["读视图:七个字段"]
V --> SCREEN["用户查看确认结果"]
图中实线表示本次实验真实执行的数据流。当前表与事件都是处理器的输出,图没有规定数据库事务、消息发布协议或事件存储。界面框代表用户可见信息,并非已经完成的网页。
逐个字段寻找来源
直接传递的六个字段,代码保留“输入位置、命令字段、事件字段、视图字段”的对应关系。持续小时数由事件中的结束时刻减去开始时刻,属于带公式的派生字段。追溯不仅要求名字存在,还逐段比较实际值;路径齐全但客户编号被替换,也会失败。
| 结果页字段 | 最早输入 | 事件中的依据 |
|---|---|---|
| 预留、设备编号 | 页面选择或填写 | 同名编号 |
| 开始、结束时刻 | 页面租期 | 同名时刻 |
| 客户编号 | 会话中的客户 | customer_id |
| 确认时刻 | 本次服务器时钟输入 | accepted_at |
| 持续小时数 | 页面开始与结束 | end − start |
这里的时刻统一使用同一天的整数小时,租期采用左闭右开区间。确认时刻 9 表示命令接收时注入的时间。若真实系统需要记录事务提交时刻,应另取实际提交时间,不能把这项教学字段直接解释为数据库的提交证据。
编号由固定表单输入提供,也是为了便于核对。换成服务器生成编号后,来源链应增加编号生成结果,而不是继续声称编号来自用户。字段追溯要求写清真实来源,不要求所有值都由用户手工输入。
成功和拒绝都经过处理器
首个操作生成 R1,租期为 [10,12)。实验实际调用命令转换、预留处理器、事件投影和追溯检查,结果页的持续时间为 2。客户字段的输出记录还包含完整路径,能够定位到 input.session.customer_id。
第二个操作更换编号为 R2,租期改成 [12,15),继续使用 E1。两段期间相邻,因此允许预留;新的结果页显示持续 3 小时。这个输入变化可以排除投影函数始终返回示例常量的错误,且不需要引入另一套模型解释器。
第三个操作 R3 请求 [11,13),与现有预留冲突,处理器抛出错误。表中仍只有 R1、R2,没有第三条成功事件供投影。当前实验把拒绝作为同步错误返回,没有伪造 ReservationConfirmed,也没有把拒绝自动建成领域事件。
拒绝消息是否需要保留,是另一个业务问题。若客服需要统计拒绝原因,可以增加明确的记录规则;若只是在页面提示冲突,应用错误可能已经足够。不能因为时间线上需要更多节点,就把所有异常都写成永久业务事实。
刷新场景再次用同一条已产生事件投影,得到同一视图,并比较刷新前后的预留表,确认没有新增预留。这里验证的是本地查询没有写副作用,不涉及 HTTP 重试、缓存失效或并发点击。
缺来源与值不一致是两种失败
flowchart TB
OK["客户 C1:会话 → 命令 → 事件 → 视图"] --> CHECK["逐段比较真实值"]
ADD["页面增加押金 100"] --> NONE["没有输入或事件来源"]
DROP["事件漏掉确认时刻"] --> FAIL["无法生成完整结果页"]
CHANGE["视图把客户改成 C999"] --> BAD["路径存在,值不一致"]
CHECK --> PASS["通过"]
NONE --> REJECT["拒绝追溯"]
FAIL --> REJECT
BAD --> REJECT
第二张图强调三种不同故障。给视图添加押金字段,追溯函数会拒绝未声明来源的字段;删除事件的确认时刻,投影阶段就失败;仅把视图客户改成 C999,字段集合仍然完整,却在值比较时失败。另一个反例把持续时间改为 999,会被公式检查拒绝。
缺少会话客户则更早失败,命令无法构造。没有把它补成空字符串或匿名客户,因为那会引入新的租赁资格政策。程序对身份证明的真实性没有能力作判断,只检查这条声明过的来源链是否完整且一致。
这些检查依赖人工编写的映射和公式。它们可以发现当前用例中的断点,不能证明遗漏的业务需求不存在。例如页面根本没有出现押金,追溯仍会通过;是否应该收取押金,需要业务讨论与场景补充。
与事件探索和存储机制的关系
EventStorming 的官方定位包括协作探索复杂业务领域,并涵盖流程、架构和软件设计等使用方式。因此不能把它简化成只贴事件便签,也不能认为命令或读模型只属于 Event Modeling。EventStorming 官方说明
本系列把两章的练习验收分开:事件探索整理发生顺序、矛盾和待确认问题;本篇则交付一条字段可追踪的信息路径,以及处理命令、生成视图的运行记录。两类产物可以互相修正。发现结果页需要某个尚未讨论的事实时,应回到业务场景确认,而不是直接增加数据库列。
本例以当前预留表保存状态,事件是处理成功后的副本。最后一个场景把已返回事件的结束时刻改成 99,表内 R1 仍结束于 12,证明这份可变副本没有成为当前状态的引用。代码没有从事件日志重建写模型,不能据此声称实现了 Event Sourcing。
同样,独立投影函数只是职责分开,并未证明系统采用了完整 CQRS 架构。它没有独立部署的查询服务、异步消费位点或最终一致性窗口。若以后加入这些机制,需要另外验证重复事件、乱序和失败恢复。
视图需求怎样反过来修正模型
假如产品要求结果页显示“押金已到账”,先要区分报价押金、应收押金和实际收款。报价可以来自定价规则,应收可以来自确认约定,到账则需要支付完成的证据。三者显示的金额可能相同,但用户据此采取的行动不同,不能共用一个来源不明的金额字段。
本例选择拒绝新增押金列,而不是填入默认值。它给出的后续问题很具体:谁提供收款结果、结果关联哪个预留、失败与等待怎样显示。只有这些事实确定后,才能增加新的命令或外部输入、事件字段及投影规则。图上的信息缺口因此可以转成可讨论的业务问题。
字段映射也应随变更一起更新。如果结果页把整数小时改成日期时间,持续时间的公式必须重新考虑时区和计量单位;路径仍然完整,不代表旧公式继续正确。当前测试只能约束固定格式的例子,不会自动推导新的时间语义。
复跑这条信息路径
在仓库根目录执行:
1 | |
Python 3.10 及以上即可运行,只使用标准库。固定输入在 models/21/fixture.json,程序在 labs/21/check.py。实际运行得到 15 条 JSON 场景记录,全部 pass=true,进程退出码为 0。原始输出与命令保存在 evidence/21/。
独立材料可下载 第21篇实验包。解压后保留仓库路径前缀,在解压根目录运行相同命令。实验不依赖 Java 基线,也没有把本章能力添加到既有租赁核心。
交互图和状态规则可对照 第05篇:UML类图与交互图、第06篇:状态流程和决策。这条预留路径的边界是单进程、固定时钟和同步投影;持久化、身份认证及消息投递仍需单独实现。






