设计模式 19 · 命令模式
2026/8/12 12:53:45 网站建设 项目流程

前面几篇的行为型,处理的是"行为怎么变"“对象怎么协作”。这一篇的命令模式(Command)换了个视角,它做一件听起来有点抽象、但威力很大的事:把"一个请求/操作"本身,封装成一个对象。

平时我们调用一个方法,是"当场执行"——service.doSomething(),调了就立刻做了,这个"操作"本身是一闪而过的、抓不住的。命令模式反其道而行:它把"要做什么"这件事,变成一个实实在在的对象(命令对象)。一旦操作变成了对象,神奇的事情就发生了——对象可以被存进变量、放进集合、传来传去、排队、记日志、甚至反过来"撤销"。这些是"当场执行"的方法调用永远做不到的。

它的核心价值,一句话:把"发出请求的人"和"执行请求的人"解耦,并且让请求本身具备可存储、可排队、可撤销、可重做的能力。打个比方:你在餐厅点菜,把需求写在"点菜单"上交给服务员,服务员传给后厨。这张点菜单就是一个"命令对象"——它把"我要一份宫保鸡丁"这个请求固化成了一个可传递的东西。你(请求发起者)不用直接跟厨师(执行者)说话;点菜单可以排队(后厨按单做);可以记录(账单);甚至可以取消(退单)。

我们的订单场景里,命令模式最能发光的地方是**“可撤销的操作”**:用户在后台对订单做了一串操作(修改地址、追加备注、改数量……),然后想"撤销上一步"、甚至"连撤三步"。如果操作都是当场执行的方法调用,撤销无从谈起;但如果每个操作都是一个"命令对象",记录下来,撤销就变成了"把命令栈弹出一个、执行它的逆操作"。这一篇我们就从这个"撤销"需求切入。

这篇文章按这条线索展开:先看"操作是一闪而过的方法调用,想撤销却无从下手"的困境;再引出命令模式如何把操作封装成对象;然后讲清它的角色、以及"撤销/重做"是怎么实现的;接着看它在现实里的身影;最后给出适用边界。贯穿例是订单操作与撤销。

目录

  1. 想撤销操作,却无从下手
  2. 命令模式:把操作封装成对象
  3. 角色,与撤销/重做的实现
  4. 现实身影:线程池任务、事务与队列
  5. 什么时候用命令模式

一、想撤销操作,却无从下手

看后台对订单的操作。最直觉的写法,是需要什么操作就直接调什么方法:

publicclassOrderAdminService{publicvoidrun(){order.setAddress("北京市朝阳区");// 改地址order.addRemark("尽快发货");// 加备注order.setCount(3);// 改数量// 用户说:"撤销刚才改的数量" —— 怎么办??}}

需求来了:用户想撤销上一步操作,甚至连续撤销好几步。这时候你会发现,上面这种写法根本无从下手:

  • 操作是一闪而过的:order.setCount(3)执行完,这个"操作"就消失了——你没有任何东西记录"刚才发生了什么操作、之前的值是什么"。想撤销,你连"撤销什么、恢复成什么"都不知道。
  • 发起者和执行者紧耦合:OrderAdminService直接调用order的具体方法,它俩焊死了。想给操作加上"记录日志"“排队执行”"权限检查"等通用能力,只能去改每一处调用。
  • 没法统一管理操作:如果想把一批操作排队、批量执行、或者做成"操作历史",你手里只有一堆散落的方法调用,没有一个统一的"操作"概念可以收集、存储。

问题的根源是:"操作"没有被表示成一个具体的东西,它只是一个转瞬即逝的方法调用。而"撤销"“重做”“排队”"记录"这些能力,统统需要先"抓住"这个操作——把它变成一个能被存储、能记住自己"做了什么、怎么撤销"的对象。我们真正想要的是:把每个操作(改地址、改数量……)都封装成一个"命令对象",这个对象知道怎么"执行"自己,也知道怎么"撤销"自己;把执行过的命令记在一个栈里,撤销就是弹栈 + 执行逆操作。这就是命令模式。

二、命令模式:把操作封装成对象

命令模式的做法:定义一个命令接口,声明execute()(执行)和undo()(撤销);每个具体操作是一个命令类,封装"对谁、做什么、怎么撤销";由一个"调用者"来触发命令,而不直接调用执行者。

第一步,定义命令接口:

publicinterfaceCommand{voidexecute();// 执行操作voidundo();// 撤销操作(逆操作)}

第二步,每个操作是一个具体命令,它持有"执行者"和"撤销所需的数据":

// 修改数量的命令publicclassChangeCountCommandimplementsCommand{privatefinalOrderorder;// 执行者(接收者)privatefinalintnewCount;privateintoldCount;// 记住旧值,用于撤销publicChangeCountCommand(Orderorder,intnewCount){this.order=order;this.newCount=newCount;}publicvoidexecute(){this.oldCount=order.getCount();// 执行前先记下旧值order.setCount(newCount);}publicvoidundo(){order.setCount(oldCount);// 撤销:恢复旧值}}// ChangeAddressCommand、AddRemarkCommand 同理,各自记住自己的撤销数据

第三步,调用者维护一个"命令历史栈",执行时入栈,撤销时弹栈:

publicclassOrderCommandInvoker{privatefinalDeque<Command>history=newArrayDeque<>();// 命令历史栈publicvoidexecute(Commandcommand){command.execute();history.push(command);// 执行过的命令入栈,为撤销做准备}publicvoidundo(){if(!history.isEmpty()){Commandlast=history.pop();// 弹出最近一条last.undo();// 执行它的逆操作}}}

用起来,操作变成了"提交命令",撤销变得轻而易举:

OrderCommandInvokerinvoker=newOrderCommandInvoker();invoker.execute(newChangeAddressCommand(order,"北京市朝阳区"));// 改地址invoker.execute(newChangeCountCommand(order,3));// 改数量invoker.undo();// 撤销"改数量" → 数量恢复invoker.undo();// 再撤销"改地址" → 地址恢复

对比第一节,升级点非常清晰:每个操作都成了一个能被存储、能自我撤销的命令对象;OrderCommandInvoker(发起者)不再直接调用order的方法,而是通过命令间接触发——发起者和执行者解耦了;而"撤销"这个曾经无从下手的需求,现在只是"弹栈 +undo()"这么简单。用一张图看这个"请求对象化"的结构最清楚:

图里最该记住的,是那条"发起者 → 命令对象 → 接收者"的间接链条,以及那个命令历史栈。命令模式的精髓,就是在"发起请求"和"执行请求"之间,插入了一个"命令对象"作为中间层——正是这个中间层,把一次性的方法调用,变成了可以被收集、排队、记录、撤销的一等公民。

三、角色,与撤销/重做的实现

命令模式的角色,四个:

角色本例中是谁职责
命令接口(Command)Command声明execute()/undo()
具体命令(ConcreteCommand)ChangeCountCommand封装"对谁、做什么、怎么撤销"
接收者(Receiver)Order真正执行操作的对象
调用者(Invoker)OrderCommandInvoker触发命令、管理命令历史

(还有一个隐含的"客户端",负责创建具体命令、指定接收者。)

重点说说撤销(undo)和重做(redo)是怎么实现的,这是命令模式最亮眼的能力:

  • 撤销 undo:靠一个"已执行命令栈"。每执行一个命令就push进栈;撤销时pop出最近一个、调它的undo()。因为每个命令都记住了自己执行前的状态(如oldCount),所以能精确回滚。连续撤销,就是连续弹栈。
  • 重做 redo:再加一个"已撤销命令栈"。撤销一个命令时,把它从"已执行栈"弹出、压入"已撤销栈";重做时,从"已撤销栈"弹出、重新execute()、再压回"已执行栈"。两个栈配合,就实现了编辑器里那种"撤销—重做—再撤销"的完整能力。

这里有个和上一篇的呼应:命令模式实现撤销,是靠"每个命令记住自己的逆操作/旧值"。如果一个操作的状态很复杂、记录"旧值"不方便,还有另一种撤销思路——直接给对象拍个"快照",撤销时整个恢复快照。那就是下一篇之后要讲的备忘录模式了。两者常常配合:命令负责"触发和管理",备忘录负责"存快照"。

四、现实身影:线程池任务、事务与队列

命令模式的身影,凡是"把操作当对象来传递、排队、延迟执行"的地方都有它:

  • Runnable/ 线程池任务:Runnable就是一个最纯粹的命令对象——它把"要执行的任务"封装成一个对象(run()就是execute())。你把Runnable提交给线程池(executor.submit(task)),线程池就是"调用者",它把任务排进队列、择机执行。“把任务对象化、提交给执行器排队执行”,正是命令模式的核心思想。
  • 消息队列 / 任务队列:把"要做的操作"封装成消息丢进队列,消费者取出来执行——这是命令模式在分布式层面的放大。请求被对象化、序列化、排队、异步执行。
  • 数据库事务与回滚日志:事务里的每个操作都记录了"怎么做"和"怎么撤销"(redo log / undo log),回滚时执行逆操作——这和命令模式的 undo 思想高度一致。
  • GUI 的菜单/按钮操作、编辑器的撤销重做:每个菜单项、每次编辑都是一个命令,编辑器的 Ctrl+Z / Ctrl+Y 就是命令栈的撤销/重做。这是命令模式的经典发源场景。
  • Spring 的JdbcTemplate回调、各种XxxCallback:把"要执行的逻辑"封装成一个回调对象传进去,也带有命令模式的影子。

一个识别信号:凡是"把一个操作/任务封装成对象,以便存储、传递、排队、延迟执行或撤销",就是命令模式。它是"任务队列"和"撤销重做"这两大功能的底层思想。

五、什么时候用命令模式

适合用命令模式的信号:

  • 你需要把"操作"存储、排队、延迟执行、或异步执行(任务队列、线程池);
  • 你需要支持撤销/重做;
  • 你想解耦"请求的发起者"和"请求的执行者",让发起者不必知道具体怎么执行;
  • 你想给一组操作统一加上日志、事务、权限等通用处理(在 Invoker 里统一做)。

不必用的信号:

  • 操作就是简单的、一次性的、不需要撤销/排队的方法调用——那直接调用最清晰,套命令模式凭空多出一堆命令类,是过度设计;
  • 没有"把操作当对象来管理"的任何需求——命令模式的价值全在"操作对象化"带来的那些能力上,用不上这些能力就别用它。

判断的核心还是那句话:先确认真的需要"撤销/重做、排队、延迟执行、发起与执行解耦"中的某一项,命令模式才值得上。为普通的方法调用套一层命令,除了增加间接层和类数量,没有收益——这是命令模式最常见的过度设计。

一个务实提醒:现代 Java 里,如果命令逻辑很简单,不必为每个命令都写一个类——直接用 Lambda 或方法引用当命令(RunnableConsumer等函数式接口),就是最轻量的命令对象。只有当命令需要携带撤销逻辑、或有复杂状态时,才值得写成完整的命令类。


小结。命令模式把"一个请求/操作"封装成对象,从而让操作这种转瞬即逝的东西,变成可以被存储、传递、排队、记录和撤销的一等公民。它在发起者和执行者之间插入"命令对象"这个中间层,实现了两者解耦;并通过"命令历史栈 + 每个命令记住自己的逆操作",优雅地实现了撤销与重做。Runnable/线程池任务、消息队列、事务回滚、编辑器的撤销重做,都是它的身影,是"任务队列"和"撤销重做"的底层思想。用它的前提是真的需要那些"操作对象化"才能带来的能力,否则就是过度设计;简单命令直接用 Lambda 即可。下一篇我们讲迭代器模式——它把"遍历一个集合"的逻辑,从集合本身分离出来,让你能用统一的方式遍历各种不同结构的集合,而不必关心它内部是数组还是链表。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询