备忘录模式在工作流草稿箱与状态回退中的实现
做政企协同办公或复杂审批流系统时,用户经常在表单填写一半时临时退出,或者在多步驳回、撤销操作时要求“一键还原到上一步编辑状态”。很多团队初期的做法简单粗暴:前端本地存 localStorage,或者后端每次操作直接往主业务表打 update,另起一张 history_log 表记录整行的 JSON dump。
这种方案在表单只有十几个扁平字段时跑得尚可,一旦接入动态表单、级联审批人、附件临时凭证和条件分支网关,问题接踵而至:直接暴露内部状态导致外部逻辑容易破坏数据一致性;深层嵌套对象的反序列化版本不兼容;大字段频繁全量快照把 MySQL redo log 和 undo log 撑爆。
备忘录模式(Memento Pattern)的核心价值在于:在不破坏封装性的前提下,捕获一个对象的内部状态并在外部保存,使对象后续能恢复到原先的状态。
业务场景与架 ground 建模
在通用工作流引擎中,一个待办审批实例(WorkflowInstance)包含当前节点、表单数据上下文(FormData)、审批人列表以及流转元数据。我们在设计草稿暂存与多级回退机制时,定义三个核心角色:
- 发起人(Originator):工作流实例本身,负责创建快照(Snapshot)和从快照中恢复状态。
- 备忘录(Memento):存储发起人内部状态的不可变对象,对外提供窄接口(只读元信息),对发起人提供宽接口(完整状态读取)。
- 负责人(Caretaker):草稿箱与回退栈管理器,负责维护快照的时间线序列,控制持久化与容量淘汰。
+------------------+ creates +----------------------+ | WorkflowInstance | ----------------------> | WorkflowMemento (宽) | | (Originator) | <---------------------- | | +------------------+ restores +----------------------+ ^ | | operates | holds (窄) +--------------------+ | | DraftHistoryStack | ---------------------------------+ | (Caretaker) | +--------------------+核心实现代码
为了防止并发修改污染快照,所有进入 Memento 的状态必须进行深度拷贝(或采用不可变集合包装)。
1. 备忘录接口与实体
package com.example.workflow.memento; import java.io.Serializable; import java.time.LocalDateTime; import java.util.Collections; import java.util.HashMap; import java.util.Map; /** * 窄接口:仅供 Caretaker 获取快照基础属性 */ public interface MementoView { String getSnapshotId(); String getStepNodeId(); LocalDateTime getCreatedAt(); String getOperatorId(); } /** * 完整备忘录:内部状态对外部包私有或仅限 Originator 访问 */ public class WorkflowMemento implements MementoView, Serializable { private static final long serialVersionUID = 1L; private final String snapshotId; private final String stepNodeId; private final LocalDateTime createdAt; private final String operatorId; // 内部保护状态:不可直接暴露可变引用 private final Map<String, Object> formValues; private final String currentStatus; WorkflowMemento(String snapshotId, String stepNodeId, String operatorId, Map<String, Object> formValues, String currentStatus) { this.snapshotId = snapshotId; this.stepNodeId = stepNodeId; this.operatorId = operatorId; this.createdAt = LocalDateTime.now(); // 制作深度防御性拷贝 this.formValues = Collections.unmodifiableMap(new HashMap<>(formValues)); this.currentStatus = currentStatus; } @Override public String getSnapshotId() { return snapshotId; } @Override public String getStepNodeId() { return stepNodeId; } @Override public LocalDateTime getCreatedAt() { return createdAt; } @Override public String getOperatorId() { return operatorId; } Map<String, Object> getFormValues() { return formValues; } String getCurrentStatus() { return currentStatus; } }2. 发起人:工作流实例
package com.example.workflow.memento; import java.util.HashMap; import java.util.Map; import java.util.UUID; public class WorkflowInstance { private String instanceId; private String currentNodeId; private String status; private Map<String, Object> formData = new HashMap<>(); public WorkflowInstance(String instanceId, String initialNodeId) { this.instanceId = instanceId; this.currentNodeId = initialNodeId; this.status = "DRAFT"; } public void updateField(String key, Object value) { this.formData.put(key, value); } public void transitionTo(String nextNodeId, String nextStatus) { this.currentNodeId = nextNodeId; this.status = nextStatus; } /** * 创建当前状态的快照 */ public WorkflowMemento createMemento(String operatorId) { return new WorkflowMemento( UUID.randomUUID().toString(), this.currentNodeId, operatorId, this.formData, this.status ); } /** * 从快照恢复状态 */ public void restoreFromMemento(WorkflowMemento memento) { if (memento == null) { throw new IllegalArgumentException("Memento cannot be null"); } this.currentNodeId = memento.getStepNodeId(); this.status = memento.getCurrentStatus(); this.formData = new HashMap<>(memento.getFormValues()); } public Map<String, Object> getFormData() { return Collections.unmodifiableMap(formData); } public String getCurrentNodeId() { return currentNodeId; } public String getStatus() { return status; } }3. 负责人:草稿箱与历史栈管理器
在服务端场景下,草稿快照不能只存在 JVM 内存的Deque中,必须配合 Redis 与持久化数据库实现分级存储。
package com.example.workflow.memento; import java.util.ArrayDeque; import java.util.Deque; import java.util.Optional; public class WorkflowCaretaker { private static final int MAX_UNDO_STEPS = 20; // 双端队列维护撤销与重做栈 private final Deque<WorkflowMemento> undoStack = new ArrayDeque<>(); private final Deque<WorkflowMemento> redoStack = new ArrayDeque<>(); public synchronized void pushSnapshot(WorkflowMemento memento) { if (undoStack.size() >= MAX_UNDO_STEPS) { undoStack.removeLast(); // 淘汰最早的历史快照,防止内存泄漏 } undoStack.push(memento); redoStack.clear(); // 产生新分支动作时清空重做栈 } public synchronized Optional<WorkflowMemento> undo(WorkflowMemento currentMemento) { if (undoStack.isEmpty()) { return Optional.empty(); } redoStack.push(currentMemento); return Optional.of(undoStack.pop()); } public synchronized Optional<WorkflowMemento> redo(WorkflowMemento currentMemento) { if (redoStack.isEmpty()) { return Optional.empty(); } undoStack.push(currentMemento); return Optional.of(redoStack.pop()); } public int getHistorySize() { return undoStack.size(); } }生产落地的权衡考量
1. 增量差量存储 vs 全量快照
如果表单包含数百个字段或大型富文本内容,每个步骤保存全量快照会造成严重的存储膨胀。在实际高频保存草稿的系统中,可以引入 JSON Patch(RFC 6902)计算前后两次状态的 Diff,草稿栈底层存储差量变更序列,仅在基线节点保存全量 Snapshot。
-- 生产草稿箱历史快照表结构 CREATE TABLE `wf_instance_memento` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `instance_id` varchar(64) NOT NULL COMMENT '流程实例ID', `snapshot_id` varchar(64) NOT NULL COMMENT '快照唯一标识', `node_id` varchar(64) NOT NULL COMMENT '所处节点ID', `operator_id` varchar(64) NOT NULL COMMENT '操作人', `patch_type` tinyint NOT NULL DEFAULT '1' COMMENT '1-全量快照 2-JSON Patch差量', `state_payload` longtext NOT NULL COMMENT '序列化快照内容', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_snapshot` (`snapshot_id`), KEY `idx_inst_created` (`instance_id`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;2. 状态过期的清理策略
草稿数据具备极强的时效性。工作流最终流转至“已归档”或“已终止”状态后,应当通过异步消息解耦触发历史快照的物理清理或冷归档。同时给草稿箱存储设置合理的 TTL(如 30 天未更新自动标记过期),避免无休止堆积脏数据。
通过备忘录模式,我们将“状态获取与恢复逻辑”封闭在领域聚合根内部,既杜绝了贫血模型下外部 Service 随意 set 内部字段的风险,又通过 Caretaker 将时序控制、撤销栈与存储介质解耦,为后续平滑接入分布式草稿同步打下扎实的基础。