ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

命令模式实战指南:从请求封装到撤销重做的完整解析

命令模式实战指南:从请求封装到撤销重做的完整解析 1. 从一道面试题说起为什么我们需要把“请求”变成“对象”工作里有个特别常见的场景不知道你遇没遇到过程序写着写着突然要在“执行某个操作”和“这个操作本身”之间加一层东西。比如 UI 上有个按钮点一下要触发一段业务逻辑但这段逻辑以后可能要换、要撤销、要排队甚至要发给另一台机器去执行。如果直接把按钮的点击事件绑死在某一个函数上那后续每加一个操作都得改按钮那块的代码改着改着就成一团乱麻了。Command 模式解决的就是这个问题。它的核心思想一句话就能说清把一次请求、一个动作封装成一个对象。这个对象里装着“要做什么”和“由谁来做”而调用方比如按钮、菜单、网络请求只需要拿到这个对象执行它的统一入口就行了根本不关心里面到底调用了谁的方法、传了什么参数。我第一次认真理解这个模式不是看书而是被一个 bug 逼的。当时做一个桌面工具工具栏上有十几个功能按键每个按键对应一个业务操作。一开始写法很直白就是一个大 switch根据按钮的 id 调不同方法。后来产品说这些操作要支持快捷键要支持宏录制还要能撤销。我一看代码头都大了——switch 里每个分支都要套一层 undo 逻辑快捷键又要复拉一遍调用宏录制还得在每个调用点埋点。这时候我才意识到我的按钮和业务逻辑耦合得太死了真正需要的不是“在按钮和功能之间不停加胶水代码”而是把每个功能先封装成一个独立的对象让按钮、快捷键、宏录制都去操作这个对象。这个模式放在 GoF 的二十三个模式里看起来结构最简单但实际应用范围极广。从编辑器里的撤销重做到任务队列里的异步命令到事务系统里的回滚日志再到游戏里的按键绑定全都是 Command 模式的变体。我个人的体会是它很难让人“眼前一亮”但几乎每个上点规模的项目里都能碰到它。你学会了它后续看别人的代码、理解框架的设计都会轻松不少。这篇文章我打算按照自己的实践经验来拆解 Command 模式先从概念和角色讲起再说代码怎么写然后结合几个常见场景展开最后把我在实际项目中遇到的坑和排查思路整理一遍。这个模式适合刚接触设计模式的初学者也适合那些“看着会但一用就乱”的老手希望你看完能对它有更立体的理解。2. 核心套路拆解四个角色各司其职2.1 一句话记住 Command 模式先给个日常类比。你去餐厅吃饭手里拿的是菜单后厨里做菜的是厨师中间传菜的服务员负责把菜单递给后厨。这里“菜单”就是命令对象它描述了你想要的结果厨师是真正的执行者命令最终会落在他手里而服务员或者叫调用者不做菜只负责传递菜单。在程序世界里四个角色分别是Command命令接口它声明一个统一的执行方法比如execute()、undo()。所有具体命令都实现这个接口。ConcreteCommand具体命令类它内部持有真正的业务对象也就是 Receiver。它做的事其实很薄——把“谁来做、做什么”装配好然后在execute()里调 Receiver 的方法。Receiver接收者真正干活的对象它知道怎么做但不主动触发。比如订单服务、播放器、文档编辑器。Invoker调用者/请求发起者持有命令对象并在某个时机调用命令的execute()。它不关心命令内部是怎么实现的。这四个角色的职责边界是 Command 模式的关键。变化的重点在于Receiver业务逻辑可以独立演进Invoker触发入口也可以独立演进它们之间只通过 Command 接口沟通。所以在一套系统里你可以有几十个 Command 类却不需要在 Invoker 里堆几十个分支。Invoker 只需要面向 Command 接口编程具体命令由外部传入。这就是“开闭原则”的典型落地新增一个操作就新增一个 Command 子类不用改 Invoker 的代码。2.2 为什么“层层委托”反而更灵活有人可能会问不就是把a.execute()换成了command.execute()嘛多一层封装到底图什么这里要展开说。直接调函数调用方和函数体是编译期绑定、写死的关系。而引入 Command 对象后调用方依赖的是抽象接口具体实现可以在运行期再决定。这意味着三件事第一你可以把命令对象的创建过程交给工厂根据配置、用户操作甚至运行时状态动态生成不同的命令。第二因为你手里的是对象你可以把它放进队列、列表、栈里可以实现批处理、延迟执行、定时执行、撤销重做。第三命令对象可以在多个调用者之间共享工具栏按钮可以持有一个命令菜单项可以持有同一个命令快捷键处理器还可以持有同一个命令。这样只要改命令内部实现所有入口行为同步变化。这些都是“函数指针”或“回调函数”难以优雅实现的事。回调解决的是“怎么调用”的问题Command 解决的是“把整个调用块当数据流转”的问题。在复杂业务系统里后者的设计空间大得多。2.3 和策略模式到底有什么区别这是初学 Command 模式时最常混淆的问题。策略模式的意图是封装算法让算法可以互相替换——同一个任务有多种做法客户端选择其中一种。比如排序策略、支付策略。Command 模式的意图是封装请求让请求的发起和请求的执行分离重点在于“把操作抽象成可传递、可延迟、可撤销的对象”。打个比方策略模式像你出门选交通工具——骑单车还是坐地铁两个方案可以随时切换而 Command 模式像你在外卖平台下单——你只管下单后厨做菜、骑手配送这些事都封装在订单这个“命令”里了你甚至可以取消订单undo。策略模式更关注“怎么做”Command 模式更关注“怎么发起、怎么管理”。区分好这两个模式对实际设计很重要因为它们的类结构有点像但如果把它们混着用后面的扩展性会大打折扣。3. 代码落地从零实现一个可撤销的命令框架3.1 先写最简单的结构理论说太多没意思直接上代码。这里我用 Python 写因为它的动态特性最适合快速验证设计思路你改成 Java、C#、TypeScript 也都是同一套结构。先定义一个命令接口from abc import ABC, abstractmethod class Command(ABC): abstractmethod def execute(self) - None: pass abstractmethod def undo(self) - None: pass再定义几个真正干活的 Receiver。比如家庭影音系统里有电视、音响、空调它们有各自的操作接口class TV: def on(self): print(电视已打开) def off(self): print(电视已关闭) class Stereo: def play(self): print(音响开始播放) def stop(self): print(音响停止播放)然后封装具体命令。比如“打开电视”这个命令持有 TV 的引用execute()调on()undo()调off()class TVOnCommand(Command): def __init__(self, tv: TV): self.tv tv def execute(self): self.tv.on() def undo(self): self.tv.off()到这里你会发现每个命令类都非常薄只负责转发。薄是正常的别想着在里面塞业务逻辑它就是个快递盒里面装的是电视的开关方法。3.2 加入调用者和撤销栈现在写一个通用遥控器它可以设置某个按钮对应的命令也可以执行所有已执行命令的撤销class RemoteControl: def __init__(self): self.slots {} self.history [] def set_command(self, slot, command: Command): self.slots[slot] command def press_button(self, slot): if slot in self.slots: self.slots[slot].execute() self.history.append(self.slots[slot]) def press_undo(self): if self.history: cmd self.history.pop() cmd.undo()用法也很简单tv TV() stereo Stereo() remote RemoteControl() remote.set_command(TV, TVOnCommand(tv)) remote.set_command(Stereo, StereoPlayCommand(stereo)) remote.press_button(TV) remote.press_button(Stereo) remote.press_undo()这个版本虽然简单但已经具备 Command 模式最核心的骨架了。Invoker 只是一个持有命令对象并按需执行的壳里面对应的是哪个 Receiver、怎么操作一概不知。你完全可以在此基础上扩展出一个漂亮的撤销机制而不需要改动已有命令类。3.3 进阶宏命令与组合命令项目的需求通常不是“按一下只执行一个动作”而是“按一下执行一串动作”。比如一键观影模式关灯、开电视、切到 HDMI、音响开机、音量调到 40。这其实就是宏命令本质上是多个命令的组合。实现方式特别简单——让宏命令本身也是一个 Commandclass MacroCommand(Command): def __init__(self, commands: list[Command]): self.commands commands def execute(self): for cmd in self.commands: cmd.execute() def undo(self): # 注意撤销要倒序执行 for cmd in reversed(self.commands): cmd.undo()用的时候movie_mode MacroCommand([ LightOffCommand(light), TVOnCommand(tv), StereoPlayCommand(stereo), VolumeUpCommand(stereo, 40) ]) remote.set_command(Movie, movie_mode)命令对象的组合特性在这里体现得淋漓尽致。因为宏命令本身也是命令它又可以作为另一个宏命令的一个子项一层层嵌套下去形成复杂的组合逻辑。这就是对象比函数更有优势的地方——函数很难把好几个函数绑在一起作为一个整体传来传去对象天生支持组合。3.4 从命令日志到事务回滚宏命令还不是最爽的最爽的是把命令应用到事务和容错上。你在写很多命令的时候可能会遇到这样的场景一个流程要依次执行 5 个操作但执行到第 3 个操作时出了异常。如果不做处理前两个操作已经生效了系统处于一个不一致的中间状态。纯函数调用很难回滚但有了命令对象和撤销栈问题就简单了。思路是这样的每执行一个命令就把这个命令入栈如果后续某个命令执行抛异常就依次弹出栈里的命令并调用undo()把所有已生效的操作回滚到初始状态。这就是“补偿事务”的思路很多分布式事务框架的实现本质也是这一套。class Transaction: def __init__(self): self.executed [] def execute(self, cmd: Command): cmd.execute() self.executed.append(cmd) def rollback(self): while self.executed: cmd self.executed.pop() cmd.undo()这里有个细节要注意命令的 undo 逻辑一定要对称而且最好幂等。什么叫对称比如 execute 是count 1undo 就得是count - 1execute 是插入数据undo 就得按主键删除而不是把整个表清了。什么叫幂等撤销操作不能因为连续调用两次而把数据搞坏最好撤销到某一步就返回“已经撤销过了”之类的提示。这些都应该是 Command 接口设计的隐含约束写代码时要时刻记住。4. 实战场景分析命令模式在真实项目里的几种典型用法4.1 编辑器撤销与重做的标准答案任何正经的文本编辑器、图形编辑器它在实现撤销重做时基本都是 Command 模式的变体。想想看一个编辑器里可能有几十上百种编辑操作插入字符、删除字符、替换选区、加粗、设置颜色……每种操作都是一步操作用户随时可以 CtrlZ 撤销再 CtrlY 重做。用 Command 模式实现特别自然每个操作封装成一个命令命令里有execute()和undo()。编辑器维护两个栈撤销栈和重做栈。执行一个操作就把命令压入撤销栈撤销时从撤销栈弹出调用undo()再把它压入重做栈重做则反过来。这个场景有一个别处不容易遇到的难点命令对象里不光要存“做了什么”还要存“对谁做”和“做之前的状态是什么”。举个例子文本编辑器里的“删除选中的字符”undo()要恢复被删掉的内容那命令对象里必须持有被删除的字符串以及删除的位置同样“插入字符”的undo()要知道在什么位置删掉多少个字符。所以很多编辑器的命令类会做得比较重它实际上是一个“快照 操作”的结合体。这个经验可以推广项目里如果命令涉及数据结构的修改最好在命令内部保存必要的前置状态不要指望从全局变量里反查。4.2 任务队列与异步执行另一个常见场景是任务队列。比如后台系统需要一批一批地执行某个操作不同操作的逻辑不同但它们都要被放到队列里排队执行执行失败还要重试、记录日志、统计耗时。如果你不把操作封装成对象你就得为每种任务各写一套队列管理逻辑而一旦封装成 Command任务队列天然就可以写成class TaskQueue: def __init__(self): self.queue deque() def add_task(self, cmd: Command): self.queue.append(cmd) def run_next(self): if self.queue: cmd self.queue.popleft() try: cmd.execute() except Exception as e: logs.append(f任务执行失败: {cmd}, 错误: {e})你以为这样就完了吗还没。因为 Command 是一个对象它可以被序列化——这意味着你可以把命令写到磁盘、发到消息队列、传到另一台服务器上执行。比如电商系统里“下单后发送短信”这种操作完全可以封装成命令先序列化发到 MQ消费者反序列化后再执行。这样做的好处是主流程不等待命令可重试且执行失败不影响主流程。4.3 游戏开发按键绑定与回放游戏开发里 Command 模式几乎是标配。玩家按下一个按键触发的可能是一个具体角色动作但按键和动作之间往往不是硬编码的。玩家可以自定义按键也就是“按键到命令”的映射表是可配置的。这种场景用 Command 模式写起来非常舒服每个技能、每个动作都是 Command配置表不过是按键到 Command 实例的映射而已。另外游戏里的录像回放、AI 决策模拟也都可以借助命令序列实现把玩家每一步操作都封装成命令并按时间戳记录回放时按时间戳依次执行命令即可。这个方案比录屏省资源得多因为录的是逻辑不是画面。4.4 分布式系统中的命令消息最后提一个比较进阶的视角在很多分布式框架里命令模式的思想也渗透得很深。比如微服务之间通过消息驱动协作消息里描述的是“做什么”而不是“调用哪个函数的第几个重载”。这种设计理念跟 Command 模式如出一辙——发送方只管发“命令”接收方拿到后解析并执行自己的业务逻辑。两者之间通过消息体解耦可以各自独立升级。当然分布式场景里的命令对象通常不是内存里的对象而是结构化的数据JSON、Protobuf但核心模型一致请求被封装成了可传输、可存储、可延迟处理的结构。5. 经验之谈我在实际项目中踩过的坑和几条处理建议5.1 常见问题排查思路汇总这一节我整理一下自己以前实际遇到过的坑。因为 Command 模式本身逻辑简单项目里出了问题往往不在模式本身而在边界情况的处理上。常见问题典型原因排查思路撤销后发现数据不对命令没有保存足够的前置状态检查 undo() 依赖的变量是不是全局的、有没有在 execute() 时被后续命令改了宏命令执行一半失败回滚不彻底宏命令的 undo 顺序错了或者子命令的 undo 不幂等按逆序逐一调用子命令 undo并且每个 undo 只负责自己当时改的状态命令对象生命周期管理混乱命令里持有大量无界引用或队列里堆积了太多命令考虑命令执行完成后清空内部状态长时间运行的系统要注意命令对象的创建和销毁新增命令后忘记注册到调用者Invoker 和 Command 解耦后注册变成了手动工作可以用反射、注解、配置表等方式做自动注册避免漏配多线程环境下共享命令对象同一个 Command 实例被多个线程同时 execute尽量让命令是无状态的或者每次执行都new一个新实例第五个坑多线程值得单独说一下。我之前写过一个系统为了省内存把一批命令对象做成单例结果多个线程同时触发同一个命令时命令内部如果有字段存“执行状态”就会出现互相覆盖、数据串了的情况。经验是命令对象最好是轻量的、无状态的如果必须存中间结果那就让每个调用者持有一个新的命令实例。这也是为什么有些框架里命令由工厂创建而不是一直复用同一个实例的原因。5.2 如何让命令类不失控随着业务越来越复杂命令类的数量会膨胀得很快。十个、二十个命令类还好一到上百个就会出现管理问题。这里分享几条我自己的习惯按业务域分包命令类不跨域。比如订单相关的命令都放在order/cmd包下库存相关的放在stock/cmd包下。命令类很薄命名格式统一代码量不大但数量多需要有清晰的组织方式。命令尽量只做转发不做校验。校验逻辑放在 Receiver 里否则不同命令之间会出现重复校验代码而且 Receiver 收到非法参数时也没法保护自己。给命令类写单元测试。因为命令类薄很多人懒得测但恰恰因为薄它的输入输出链路短很容易把 mock 后的 Receiver 注入进去做验证测试成本低收益高。注意命令的可序列化设计。如果命令可能进入消息队列或者日志系统给它设计好序列化/反序列化方案不要让内部对象的引用直接出现在传输结构里。5.3 什么时候不要用 Command 模式任何模式都有适用边界Command 也不例外。如果你的需求极简单调用方和业务逻辑永远一一对应、不会有撤销重做也不会因为异步或配置而解耦那么直接调用函数比强行套 Command 模式更实在。设计模式本质上是给未来可能的变化留余地如果变化根本不存在那些多余的间接层只会徒增阅读负担。反过来如果代码里已经开始出现下面这些信号你就该考虑入手 Command 模式了同一个操作要在多个入口触发而你现在正想方设法地抽公共方法你需要支持撤销/重做但现有代码让它很难实现你要把操作放入队列或者延迟执行你的操作需要被记录、回放、远程传输。出现以上任意一两条Command 模式就是个很自然的选择。5.4 搭配其他模式效果更上一层楼Command 模式很少单独存在它经常会和其他模式搭配使用。工厂模式 Command 模式是最经典的组合。命令对象的创建往往不是手写 new而是由工厂方法根据配置或上下文创建。这样调用者彻底不依赖具体命令类只面向 Command 接口编程。组合模式 Command就是我前面讲的宏命令命令可以被嵌套形成一棵命令树。责任链模式 Command会出现于消息处理场景一条命令可能要被多个处理器依次处理每个处理器决定继续传递还是中断执行。备忘录模式 Command在编辑器场景中经常配对命令负责执行备忘录负责记录状态两者合起来可以实现更精准的撤销。我第一次真正把这几个模式组合起来是在做一个中间件系统的时候外部请求到达后先被封装成 Command再丢进一个责任链里做鉴权、限流、参数校验最后执行并记录日志。整个链路每个环节都只处理自己关心的那部分代码清晰了不少。6. 写在最后一个小技巧最后分享一个我自己的心得也是每次在代码里用 Command 模式时的习惯动作新写一个命令类之前先问自己三句话——这个命令的 Receiver 是谁它需要保存什么状态才能撤销它的 execute 会不会抛异常抛了异常由谁负责回滚这三个问题回答清楚命令类的设计基本不会跑偏。设计模式这个东西光看书不如在实际代码里踩一次坑。我建议你找个周末把你手头项目里最乱的一个“按钮业务逻辑”模块翻出来重构成 Command 模式把撤销、队列这些顺手加上。等你真正把这段封装做完你会发现下一次遇到类似需求时脑子里自然就会浮现这个结构不再需要刻意去查书。
返回列表