命令模式在Go中通过结构体组合实现,要求统一Execute()和Undo()方法签名,用双栈管理撤销重做,避免全局状态,合理处理大对象内存,并用组合命令保障操作原子性。

命令模式的核心是把操作封装成可执行对象
Go 没有接口继承或抽象类,所以命令模式不是靠“实现 ICommand 接口”来驱动,而是靠函数签名一致性和结构体组合。关键在于定义统一的 Execute() 和 Undo() 方法签名,并让每个具体命令都满足它。
常见错误是直接用 func() 类型存操作——这没法带状态,也无法支持 Undo()。必须用结构体封装:操作本身 + 执行所需上下文(比如被修改的对象、原始值、参数等)。
- 每个命令结构体至少包含
Execute()和Undo()两个方法,返回error便于链式错误处理 - 避免在命令里直接操作全局状态;应通过字段传入依赖对象(如编辑器实例、画布、配置项)
- 如果命令需要异步执行或带超时,不要在
Execute()里启动 goroutine 后立刻返回——这会让撤销逻辑失去同步依据;要么阻塞等待,要么把“完成信号”纳入命令生命周期管理
撤销重做栈必须区分“已执行”和“待重做”状态
单纯用一个切片模拟栈不够。典型错误是只维护一个 []Command,执行后 append(),撤销时 pop(),但重做时没地方取回被撤销的命令——因为它们已被从切片末尾删掉了。
正确做法是用两个切片:history 存已执行且未被撤销的命令,redoStack 存刚被撤销、尚可重做的命令。每次 Execute() 后清空 redoStack(因为新操作使旧重做失效)。
-
Undo():从history弹出最后一个命令,调用其Undo(),再推入redoStack -
Redo():从redoStack弹出,调用其Execute(),再推入history - 注意边界检查:对空栈调用
Undo()或Redo()应静默忽略或返回明确错误(如errors.New("no command to undo")),而不是 panic
Go 中命令对象的内存管理容易被忽视
命令结构体若持有大对象指针(比如一整张图片的 *image.RGBA),多次撤销重做会不断复制指针,但底层数据不会释放——尤其当命令还被栈引用时,GC 无法回收。这不是 Go 的 bug,而是模式使用不当。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
解决方案取决于场景:
- 对小状态(坐标、字符串、数字),直接值拷贝安全高效
- 对大对象,命令中只存必要差异(diff),比如“将位置 X 从 100 改为 120”,而不是存整个对象快照
- 若必须存快照,考虑用
sync.Pool复用命令结构体,或在Undo()/Redo()后显式置空大字段(如c.img = nil) - 测试时用
runtime.ReadMemStats观察命令频繁创建是否引发堆增长异常
实际集成时要注意命令粒度和组合边界
用户一次点击可能触发多个原子操作(比如“删除选区”涉及清除像素、更新选区框、刷新状态栏)。把这些全塞进一个命令里,会导致撤销粒度太粗;拆成多个命令,又可能破坏一致性(比如只撤销了像素清除,没撤销状态栏更新)。
推荐做法是用组合命令(CompositeCommand):它内部持有一组子命令,在 Execute() 中顺序执行全部,在 Undo() 中倒序撤销全部。关键点是它自己也实现 Command 接口,能被压入主栈。
- 组合命令的
Undo()必须保证原子性:任一子命令Undo()失败,应尝试恢复已撤销的部分(即“反向重做”),或明确返回错误并记录失败位置 - 避免递归嵌套过深——5 层以上组合会让调试和内存分析变得困难
- 日志或调试时,可通过
fmt.Printf("%T", cmd)快速识别是原子命令还是组合命令
真正难的不是写完 Execute() 和 Undo(),而是在状态变更路径上始终知道“当前哪部分已被持久化、哪部分还在内存栈里、哪个命令持有对外部资源的唯一引用”。这些细节不会报错,但会在某个并发请求或长时间运行后突然暴露。










