在复杂业务系统中,如何可靠地追踪每一次状态变化?事件溯源(Event Sourcing)将每次变更建模为不可变事件,通过重放事件重建当前状态。F# 凭借可区分联合、不可变数据结构和函数组合能力,成为实践事件溯源的理想语言。本文聚焦 F# 事件溯源模式的组合应用实战,帮助你构建可审计、可扩展的领域核心。
一、事件溯源模式的核心思想
传统 CRUD 只保存最新状态,业务意图和历史变更容易丢失。事件溯源模式不直接存储状态,而是顺序记录所有领域事件,例如订单已创建、商品已添加、支付已完成。当前状态由事件流投影得到,历史任意时间点也能通过重放事件精确重建。
在 F# 中,事件天然适合用可区分联合(Discriminated Union)表示。每个事件类型携带业务数据,不可变记录保证事件一旦产生就不会被修改。
事件类型定义
使用可区分联合定义事件,比使用继承或字符串标签更安全。模式匹配在处理事件时提供编译期检查,减少遗漏分支的风险。事件类型可以包含订单 ID、客户 ID、金额、商品明细等字段,使业务语义直接体现在类型系统中。
二、F# 组合应用实战:从命令到投影
事件溯源应用可以抽象为一条函数管道:命令到达、加载事件、执行领域逻辑、产生新事件、持久化事件、更新投影。F# 的函数组合和管道运算符让每个环节清晰可复用。
1. 命令与事件分离
命令表达意图,事件表达事实。命令可能被拒绝,事件已经发生。设计时用不可变记录表示命令,用可区分联合表示事件,避免将命令对象直接存入事件流。例如创建订单命令包含客户 ID 和初始金额,而订单已创建事件包含订单 ID、客户 ID、创建时间等事实信息。
2. 领域决策函数
核心函数接收当前状态和命令,返回新事件列表。函数签名保持纯粹:不读取数据库、不写入日志,只基于输入计算输出。这种纯函数容易单元测试,也能组合多个子决策。例如,账户聚合的决策函数可以组合存款、取款、转账等子函数。通过 Result 类型返回成功事件或业务错误,避免异常控制流。
3. 事件存储与重放
事件存储是追加写入的日志,事件按聚合 ID 和版本号排序。重放时按顺序折叠事件,得到当前状态。F# 的 List.fold 或 Seq.fold 天然适合状态重建。投影函数接收旧状态和事件,返回新状态,这样读取模型始终可以从事件流推导。
三、实战示例:订单组合应用
下面通过订单场景展示组合应用。订单事件包括已创建、商品已添加、已支付、已发货。订单状态由事件投影而来。
- 订单已创建:包含订单 ID、客户 ID、初始金额。
- 商品已添加:包含商品 ID、数量、单价。
- 支付已完成:包含支付方式、支付时间。
- 订单已发货:包含物流单号、发货时间。
将命令处理函数与事件应用函数组合:命令处理函数加载历史事件并折叠为当前状态,再调用决策函数生成新事件;事件应用函数只负责把事件叠加到状态。两者分开测试和复用。
函数组合管道
使用 F# 管道运算符组织代码:
events |> load |> fold applyEvent |> decide command |> persist
其中 applyEvent 是投影函数,decide 是领域决策函数。每个函数只做一件事,组合后形成完整用例。组合优于继承的设计让业务逻辑模块化,新增事件类型时只需扩展联合类型和对应投影分支,不必改动整体流程。
四、最佳实践与注意事项
- 事件版本化:业务变化时新增事件类型或字段,保留旧事件读取逻辑,避免破坏历史事件流。
- 幂等处理:事件存储写入应支持幂等键,防止重复命令产生重复事件。
- 投影独立存储:读取模型可以独立建表,通过订阅事件流更新,实现 CQRS。
- 避免过大的聚合边界:聚合太大导致事件流重放变慢,合理划分聚合保证性能。
在事件存储选型上,可以选择数据库事件表、专用事件存储或消息日志。无论哪种存储,事件写入必须满足原子追加和顺序保证。F# 的异步工作流和结果类型可以优雅处理存储失败、并发冲突和重试逻辑。
五、总结
F# 的函数式特性与事件溯源模式高度契合。可区分联合让事件定义清晰,不可变数据保证事件不可篡改,函数组合让命令处理、事件应用、状态投影自然衔接。通过本文的组合应用实战思路,你可以在订单、账户、库存等场景中落地事件溯源,构建可审计、高可靠的领域系统。掌握事件溯源不是引入复杂框架,而是用正确的类型和纯函数塑造可演进的业务核心。