命令模式实战:将请求封装为对象,实现撤销、队列与宏命令
写代码的人可能都有过这种经历一堆按钮的点击事件里塞满了业务逻辑每个按钮背后new一个处理器等业务方说要增加“撤销上一步”功能时你发现根本无从下手因为操作的历史记录压根没存再后来产品又提了“把N步操组合成一个快捷键宏”你只能在每个按钮监听器里复制粘贴同一段代码。问题的根源在于你把“发请求”和“处理请求”死死焊在了一起。命令模式解决的就是这一件事——把操作本身变成对象让请求可以被传递、排队、记录、撤销。命令模式是23种GoF设计模式里的第14个归类在行为型模式家族。Java、C#、C、Java Script 都适用也是软考、校招面试里特别爱考的一道题。这篇文章我把话说直白一些先讲清楚命令模式的本质和五个角色再放一份可以直接抄的Java代码最后结合真实的框架场景说它的应用还会专门聊面试速记和容易踩的坑。1. 先想清楚请求是怎么变成“对象”的1.1 一个让所有代码变僵硬的场景假设你正在写一个智能家居控制App界面上一排按钮开灯、关灯、开空调、调温度。如果采用最直觉的做法按钮的点击事件会直接去调用对应电器的接口。比如buttonOpenLight.setOnClickListener(() - light.on()); buttonCloseLight.setOnClickListener(() - light.off());这看起来没什么问题但程序继续演进就难受了用户要“撤销刚刚的操作”可每个按钮只知道自己执行了什么没有统一的对象去记录曾经执行过的动作用户要“一键回家模式”把开灯、开空调、开电视放到同一个操作里你得写一堆调用用户要“定时执行命令”你只能另起线程去调。问题出在哪儿按钮调用者和电灯接收者直接耦合了。按钮知道得太多它不仅要关心“用户点了我之后要做一件事”还要亲自知道“这件事具体落到谁身上、怎么落地”。1.2 把“动词”变成“名词”命令模式的直观理解命令模式的核心思路是用一个对象来代表一次操作请求。原本“调用某个方法”是一个动词、一种动作动作一旦发生就结束了现在你把动作封装成一个命令对象这个对象就是名词可以被存进数组、放进队列、传到函数参数里甚至序列化到磁盘上。举个生活中的例子。你在餐厅点菜有两种方式方式A你直接跑进后厨对厨师喊“给我炒一盘番茄鸡蛋”。方式B你在菜单上勾选菜品服务员把菜单传到后厨厨师照着菜单做。方式B的菜单就是一个命令对象。你调用者不需要认识厨师接收者菜单命令对象在中间传递可以轻松实现“取消订单”“加一份”“把几道菜组合成套餐”。这就是命令模式的精髓。对应到代码里命令模式的要义就是Invoker调用者不直接操作Receiver接收者而是持有一个Command接口调用Command.execute()由ConcreteCommand去调用接收者的对应方法。整个过程看下来请求就被封装成了一个独立的对象。命令模式适合这些场景需要把操作排队执行、需要记录操作历史以便撤销重做、需要把一组操作组合成宏命令、需要在执行前后统一加日志或权限校验。如果你在业务里撞到了其中任何一种诉求基本可以确定用命令模式能让代码结构清晰一大截。2. 命令模式的核心角色与职责边界2.1 五个角色各管一段命令模式一共涉及五个角色初学者最常犯的错是把它们混为一谈。我逐个拆开说。Command抽象命令定义一个执行操作的接口通常只有一个execute()方法有的实现还会加undo()。ConcreteCommand具体命令绑定一个接收者和一组参数在execute()里调用接收者的业务方法。它把“调用哪个方法、传入什么参数”都锁死了。Receiver接收者真正干活的业务对象。它知道如何完成一个具体操作但不关心命令是谁发来的。Invoker调用者/请求者持有一个命令对象并在某个时机去调用command.execute()。它完全不关心命令内部是怎么实现的。Client客户端创建具体命令对象并设置它的接收者再把命令交给调用者。这五个角色之间的关系我习惯用一句话概括客户端负责“组装”——创建命令、绑定接收者、塞给调用者调用者负责“触发”——在恰当的时机执行命令命令负责“桥接”——连接调用者和接收者接收者负责“落地”——真正干业务。关键是调用者手里拿的永远是Command接口它看不到接收者也不关心命令内部到底调了谁。这样新增一种操作你只需要新增一个ConcreteCommand类调用者一行代码都不用改。2.2 协作关系谁创建、谁持有、谁调用很多资料把命令模式的UML图画得很复杂实际上协作流程非常清晰就三步Client创建ConcreteCommand对象并把Receiver传给它的构造函数。Client把ConcreteCommand对象设置到Invoker里Invoker内部持有的是Command接口类型。某个外部时机触发后Invoker调用command.execute()命令内部调用绑定好的Receiver方法。注意接收者的绑定发生在命令对象创建那一刻但命令的执行可能会延迟很久。这就是命令对象真正的价值它把“发生”和“执行”解耦了。创建命令时操作还未发生执行命令时操作才真正落地。中间这段时间里你可以把命令存起来、排队、甚至重新排序。还有一个值得强调的细节命令对象execute()的参数通常应当在构造时传入而不是作为execute()的参数。因为Command接口要统一不同命令需要的参数千奇百怪如果把参数塞进execute()接口就没办法收敛了。正确做法是用构造参数或成员变量保存参数。3. 把命令模式写到代码里一份可以直接抄的Java实现3.1 基础实现电灯与遥控器我先写一个朴素版本帮你建立整体骨架。场景很简单一个遥控器控制电灯开关。接收者Lightpublic class Light { private boolean on false; public void on() { on true; System.out.println(灯亮了); } public void off() { on false; System.out.println(灯灭了); } }命令接口public interface Command { void execute(); }具体命令public class LightOnCommand implements Command { private Light light; public LightOnCommand(Light light) { this.light light; } Override public void execute() { light.on(); } }public class LightOffCommand implements Command { private Light light; public LightOffCommand(Light light) { this.light light; } Override public void execute() { light.off(); } }调用者遥控器public class RemoteControl { private Command slot; public void setCommand(Command command) { this.slot command; } public void pressButton() { if (slot ! null) { slot.execute(); } } }客户端组装public class Client { public static void main(String[] args) { Light light new Light(); Command lightOn new LightOnCommand(light); Command lightOff new LightOffCommand(light); RemoteControl remote new RemoteControl(); remote.setCommand(lightOn); remote.pressButton(); // 灯亮了 remote.setCommand(lightOff); remote.pressButton(); // 灯灭了 } }这段代码的扩展性在哪里如果新增一个电视你只需要新增Receiver类和对应的TVOnCommand、TVOffCommandRemoteControl一个字都不用改。如果新增一个“一键回家模式”你可以写一个MacroCommand去组合多个命令依然不动RemoteControl。这才是设计模式真正的目的——把变化点封住不让它波及稳定代码。3.2 带撤销的进阶实现命令模式的高光时刻命令模式最让人心动的能力是撤销。实现撤销有两种思路一种是给命令加undo()方法在命令内部记录上次执行前的状态撤销时恢复。另一种是不改变接收者而是记录“反向命令”执行命令时压栈一条反向操作撤销时弹出并执行。用状态快照的方式写一个可撤销的调温命令public class Thermostat { private int temperature 20; public void setTemperature(int temperature) { this.temperature temperature; System.out.println(当前温度 temperature 度); } public int getTemperature() { return temperature; } }public class SetTemperatureCommand implements Command { private Thermostat thermostat; private int targetTemperature; private int prevTemperature; public SetTemperatureCommand(Thermostat thermostat, int targetTemperature) { this.thermostat thermostat; this.targetTemperature targetTemperature; } Override public void execute() { prevTemperature thermostat.getTemperature(); // 先记下旧值 thermostat.setTemperature(targetTemperature); } public void undo() { thermostat.setTemperature(prevTemperature); } }调用者再加一个撤销栈import java.util.Stack; public class RemoteControlWithUndo { private Command slot; private StackCommand history new Stack(); public void setCommand(Command command) { this.slot command; } public void pressButton() { if (slot ! null) { slot.execute(); history.push(slot); } } public void pressUndo() { if (!history.isEmpty()) { Command last history.pop(); if (last instanceof UndoableCommand) { ((UndoableCommand) last).undo(); } } } }这里我额外定义了UndoableCommand接口只含undo()。注意撤销栈保存的是命令对象而不是接收者的状态所以每次执行都要把命令对象压栈。只要有多个遥控器按钮也可以给每个按钮各配一个历史栈这就是编辑器的“撤销只针对当前文档”这种需求了。3.3 宏命令组合的艺术宏命令的本质是复合模式在命令模式里的应用——一个命令内部聚合了一组子命令。典型实现import java.util.ArrayList; import java.util.List; public class MacroCommand implements Command { private ListCommand commands new ArrayList(); public void addCommand(Command command) { commands.add(command); } Override public void execute() { for (Command command : commands) { command.execute(); } } }如果要支持宏撤销同理在undo()里倒序遍历子命令列表逐个撤销。注意宏撤销的顺序要和执行顺序相反否则可能产生逻辑错误。我实际做项目时经常配合CompositeCommand处理批量操作一个表单提交按钮需要校验参数、写入主表、写入子表、发消息通知这些可以封装成四个命令并组合成一个SubmitCommand后续如果要在提交前统一加日志只需在execute()前后打点不用改动四个子命令的代码。4. 命令模式在真实世界里的样子框架与应用拾遗4.1 JDK和GUI框架里的命令模式命令模式不是课本里才有Java生态里随处都是它的身影。java.lang.Runnable线程要执行的代码被封装成对象提交给线程池后由线程调度执行。这不就是命令模式吗ThreadPoolExecutor.execute(Runnable command)参数就叫command。Swing/AWT的ActionListener、JavaFX的EventHandler按钮点击、菜单选择都是把用户操作封装成监听器对象交给事件分发线程去触发。虽然它更接近观察者模式但事件对象本身也承载了命令的意味。javax.swing.Action这个更纯粹它把按钮的文本、图标和一个actionPerformed动作封装在一起一个Action可以直接被多个按钮、菜单项复用。如果你用过Swing写工具栏对这种“一个动作对象挂在多处”的感觉肯定不陌生。Spring框架里的事务管理接口TransactionCallback把要在事务里执行的代码块封装成对象由事务管理器统一开启、提交、回滚。事务回滚本质上就是命令模式里撤销动作的框架级实现。再说一个日常开发里几乎天天碰的场景Android里把Runnable对象通过Handler.post()投递到消息队列JS里把回调函数传给setTimeout到时间后执行。函数是第一公民时函数本身就能当命令对象用。命令模式的使用范围比我们意识到的要广得多。4.2 事务与操作日志命令模式的延伸价值如果把命令对象序列化到磁盘你还能得到另一种强大的能力——持久化操作日志。数据库的redo log、undo log就是典型每次修改被拆成一条日志记录崩溃后可以重放日志恢复数据也可以根据日志反向操作来回滚。我们在业务系统里也可以借鉴这个思路。比如管理后台的“批量审核”功能后台管理员勾选100单每单审核通过是一条命令把这100条命令记录下来。用户反悔要批量撤回审核时直接执行100条对应的反向命令。这套方案远比给每个单据加“是否已审核”状态位要健壮因为命令日志完整记录了“曾经发生过什么”而不是只记录了“现在是什么状态”。还有一个很实用的场景是延迟队列和定时任务。比如下单后30分钟未支付自动关闭订单你可以在下单那一刻创建一个CloseOrderCommand带上订单号存入延时队列。到期后队列触发命令的execute()。如果之后业务规则调整想把30分钟改成15分钟你只需要调整命令的触发时间不需要改动订单处理逻辑。这就是把“动作”对象化之后带来的灵活性。5. 命令模式最容易混淆的三种模式对比与选型5.1 命令模式与策略模式都是封装行为但出发点不同面试里最爱问的一个问题是命令模式和策略模式结构上很像到底有什么区别两者的类图确实有个共同特征都由一个接口、多个实现类、一个上下文类构成。但它们的意图天差地别。策略模式解决的是“同一件事有很多种做法需要动态切换”。比如运费计算有顺丰、圆通、中通多种策略客户端在运行时选择一种策略塞给上下文。策略模式关注的是算法的替换通常一次只执行一个策略。命令模式解决的是“发起操作的人和执行操作的人要解耦且操作本身要能作为对象存储、传递”。命令的关注点是动作的触发和执行分离而且可以记录、撤销、组合。用一句话区分策略模式问“怎么做”命令模式问“谁来做、何时做、做完还能不能反悔”。策略模式的核心是Context中持有策略接口以便切换算法命令模式的核心是Invoker中持有命令接口以便延迟触发。5.2 IDE里的那个玩笑题按钮到底是观察者还是命令另一个常见困惑按钮点击事件的处理程序是命令模式还是观察者模式其实两个模式在这里是协同出现的。按钮被点击后事件分发器相当于被观察者会通知所有已注册的监听器观察者这个过程是观察者模式的工作而监听器本身把“点击”这个动作封装成ActionEvent对象并触发事件驱动的部分又带有命令模式的影子。Swing里Action类的设计则更接近命令模式动作对象自己持有参数和执行逻辑挂在按钮和菜单两个地方复用。所以正确答案是按钮监听机制的骨架是观察者模式但Action对象和事件对象承载的动作语义沿用了命令模式的思路。面试时能把这两层拆开讲清楚会让面试官高看你一眼。5.3 与职责链模式请求的流向截然不同命令模式和职责链模式都处理“请求”但请求的流动方式不同。命令模式的请求是直接指向一个明确的接收者命令内部把接收者绑定了从一而终。职责链模式的请求沿着链上的处理器逐个传递请求到达哪个处理器在运行时才能确定可能被第一个处理器拦截也可能走到链尾。说白了命令模式是“点对点”职责链模式是“链式接力”。如果你要实现登录校验、参数校验、权限校验这种层层过滤的请求应该用职责链如果你要实现的是“把某个操作暂存、排队、撤销”应该用命令模式。综合来看命令模式与这些模式共存的情况也不少见——比如多个命令组成宏命令是命令与组合模式结合命令执行后发事件通知外部是命令和观察者结合。模式本身就像积木关键是根据意图搭配。6. 软考与面试高频考点速记口诀与答题模板6.1 命令模式在23种设计模式里的坐标与记忆GoF把23种设计模式分为创建型、结构型、行为型三类。命令模式属于行为型而且是行为型家庭里资格很老的一位。软考大纲里命令模式是“设计模式三大类”中的行为型模式和策略、观察者、职责链、状态、模板方法并列。它的位置常常这样考给出一个场景描述要求你在选项中选出适用模式比如“需要将请求封装为对象以支持撤销和记录日志”答案就是命令模式。记忆口诀可以这么来“请求封装成对象触发执行两分离排队撤销宏命令命令模式真给力。”更进一步的口诀是抓住三个关键词封装、解耦、可撤销。任何一道题只要提到“请求/操作被封装成对象”、“支持撤销/重做”、“记录操作历史”优先怀疑命令模式。另外23种模式的三类记忆可以按“创建-结构-行为”串一遍创建型管对象怎么造结构型管类与对象怎么组合行为型管对象之间怎么协作。命令模式是协作方式的经典代表它把“请求”当成一种可以传递的协作媒介。6.2 面试现场从结构到代码的完整作答线路面试官问“请讲一下命令模式”时一个高分的作答应当按这个顺序组织一句话定义把方法调用封装成独立对象实现请求发送者和接收者的解耦。讲五个角色Command抽象接口、ConcreteCommand、Receiver、Invoker、Client简要说明各自职责。举具体的应用场景至少举两个一个业务案例按钮操作、撤销、一个框架案例Runnable、事务、日志。画出大致结构关系不要求标准UML但要把依赖方向说清楚——Invoker依赖Command接口ConcreteCommand依赖Receiver。补上优缺点优点是可扩展、可撤销重做、可组合命令、符合开闭原则缺点是有可能产生大量命令类如果业务简单就没必要硬套。如果能接一句“它与策略模式的区别是……”面试分基本稳了。软考下午题如果让画类图或补代码高频考点是execute()方法的实现里必须通过命令内部持有的Receiver去调用具体业务方法而不是在调用者里直接new接收者。如果写成了Invoker直接调用接收者那题目的命令模式核心思想就没落实到位。6.3 做题容易失分的两个坑我在改学生作业和看代码评审时经常发现两个典型错误这里重点提醒。第一个坑把execute()写成一个模棱两可的“算法执行”把命令模式用成了策略模式。比如命令接口只有execute()但每个命令的execute()内部逻辑天差地别没有一个明确的Receiver。这样确实也能跑但命令对象就会变得很大学不到解耦的精髓。正确做法是命令对象应当尽量“薄”真正的业务要下沉到Receiver里。第二个坑支持撤销的命令没有保存状态或者保存状态的位置不对。撤销操作必须知道“上一个状态是什么”这个状态一定要在execute()执行前快照好并保存在命令对象内部。如果在execute()执行完以后才从接收者取当前值一旦连续执行两条命令再撤销恢复的就不是执行前的状态了。撤销栈的数据结构也要选对——必须用栈保证后进先出才能实现逐级回退。7. 我在实际项目里用命令模式的一点体会7.1 我把操作按钮们收编成了“命令池”曾经做过一个运营配置平台页面上一堆按钮上线、下线、编辑、复制、同步、回滚。初期代码就是每个按钮一个事件逻辑散落各处。后来需求方要求所有操作都要记录操作日志所有敏感操作都要二次确认某些操作要支持定时执行。这三大需求要是直接在按钮事件上改代码会变成蜘蛛网。我重构的思路很简单为每一个后台动作定义一个OperationCommand命令行里包含操作人、操作时间、操作参数。然后在调用者一个统一的操作分发器里做了三件事——执行前记录日志、执行前检查二次确认标志位、执行后把命令压入历史栈。新增操作时只需要新增一个命令类并注册到分发器日志、鉴权、撤销这些横切逻辑完全复用。这套结构上线后有个惊喜由于每个操作都被命令对象化需求方后来要“一键批量执行方案”我直接写了一个组合命令脚本把多个OperationCommand串起来跑十分钟搞定。如果沿用原来的散装按钮事件这个需求估计得改一整天。7.2 什么时候不建议用命令模式命令模式虽好但不要为了用而用。如果你的业务场景完全不涉及撤销、排队、组合、日志这些诉求只是简单的“按钮点击调用一个方法”直接调用反而更清晰——硬加一层Command接口只会多出一堆类徒增维护成本。我的个人原则是当同一个操作需要在多个入口被触发、或者操作本身需要被记录/复用/排队时才引入命令模式。设计模式是在复杂度和灵活性之间找平衡不是越复杂越好。如果团队里都是业务取向的同学简单逻辑还硬套模式反而容易引发阅读障碍。还有一个工程细节命令对象如果要做持久化记得实现Serializable并且命令里不要持有数据库连接池、线程池这类不可序列化的资源。我最初设计延迟关闭订单命令时顺手把OrderService实例塞进了命令结果命令根本无法序列化到Redis浪费了半天时间排查。正确做法是命令里只保存必要的业务ID执行时再通过服务查找上下文。7.3 一个小技巧用命令对象统一做参数校验和权限控制最后分享一个实用技巧。因为所有操作都收口到execute()你可以把参数校验、权限校验放在命令执行前统一处理。具体做法是引入一个命令执行器public class CommandExecutor { public void execute(Command command, Operator operator) { if (!command.validateInput()) { throw new IllegalArgumentException(参数不合法); } if (!permissionService.check(operator, command.requiredPermission())) { throw new PermissionDeniedException(无权限); } command.execute(); commandLogService.log(operator, command); historyStack.push(command); } }这样一来每个命令只需要提供validateInput()和requiredPermission()剩下的横切逻辑全部收敛在CommandExecutor中。命令对象成了业务流程和管理逻辑的统一载体代码的层次感一下就起来了。回到开头那个问题为什么我们要把请求变成对象因为一个对象可以被赋值、被存储、被传递、被序列化、被观察一个赤裸裸的方法调用却什么也留不下。命令模式的本质就是把“发生过的动作”变成“可管理的资产”。理解了这一层你就不会再觉得这只是一个考试模式它会在你设计系统时成为工具箱里一把实实在在的趁手工具。