GoF设计模式——命令模式

本文是【GoF设计模式】系列第15篇,更多内容欢迎关注公众号:咖啡八杯

前言

为什么需要命令模式?

想象一个文本编辑器的撤销功能:用户输入了一段文字,然后按 Ctrl+Z 撤销。最直觉的写法是在每个操作方法里保存历史状态:

java 复制代码
class TextEditor {
    private StringBuilder content = new StringBuilder();
    private List<String> history = new ArrayList<>();

    public void insertText(String text, int position) {
        history.add(content.toString());  // 保存当前状态
        content.insert(position, text);
    }

    public void undo() {
        if (!history.isEmpty()) {
            content = new StringBuilder(history.remove(history.size() - 1));
        }
    }
}

这种写法很快就会失控:每加一种操作(删除、替换、格式化)就要在对应方法里手动保存历史,TextEditor 既要管业务逻辑又要管撤销状态,职责混乱。操作一多,历史管理代码散落在各处,谁也不敢碰这块代码。

命令模式解决的就是这个"把操作本身变成可以存储、排队、撤销的对象"的问题。每个操作封装成一个命令对象,编辑器只管持有命令、在合适时调用,需要撤销就调用命令的 undo() 方法,互不干扰。

概念

命令模式(Command Pattern)是一种行为型设计模式 ,核心思想是将请求封装为对象,从而使你可以用不同的请求对客户进行参数化,支持请求的排队、记录日志以及撤销操作。

命令模式的灵魂是"把请求变成对象"------可以存储、排队、撤销、组合。 Command 代表一个可以被存储、排队、撤销的操作

命令模式包含四个角色:

  • Command(抽象命令类) :声明执行操作的接口,通常包含 execute() 方法
  • ConcreteCommand(具体命令类) :将一个接收者对象绑定于一个动作,实现 execute() 方法,调用接收者的相应操作
  • Receiver(接收者) :真正执行请求的对象,知道如何实施与执行一个请求相关的操作
  • Invoker(调用者) :持有命令对象并调用其 execute() 方法发送请求
classDiagram direction BT class Command { <<interface>> +execute() } class ConcreteCommand { -receiver: Receiver +execute() } class Receiver { +action() } class Invoker { -command: Command +setCommand(command) +executeCommand() } ConcreteCommand ..|> Command : 实现 ConcreteCommand o--> Receiver : 持有 Invoker o--> Command : 持有

图中各类之间的关系:ConcreteCommand 实现 Command 接口并持有 Receiver 引用,Invoker 持有一个 Command 引用------InvokerReceiver 之间没有直接依赖。

可以把命令模式想象成餐厅点菜:顾客(客户端)跟服务员(Invoker)说"来一份宫保鸡丁",服务员不自己下厨,而是把需求写成订单(Command)转交给后厨厨师(Receiver)。订单可以排队、可以取消、可以记录,服务员和厨师完全解耦。

Invoker 负责调度(什么时候做),Command 负责绑定(把"谁做"和"做什么"绑在一起)。Invoker 不该知道 Receiver,就像调度员不该替老板决定谁干活。

如何区分哪些操作是命令?如果一个操作需要满足以下任一条件,就应该考虑将其设计为命令:

  • 需要撤销/重做:操作需要记录历史状态,支持回滚
  • 需要排队执行:操作需要按顺序执行或延迟执行
  • 需要事务支持:多个操作需要作为一个原子单元执行
  • 需要日志记录:操作需要被记录以便审计或恢复
  • 需要解耦发送者和接收者:发送者不应该知道接收者的具体类型

实现

标准实现

GoF 推荐 Receiver 由客户端创建并注入到具体命令中,原因如下:

  • 职责分离:客户端知道业务上下文,能决定使用哪个接收者;调用者只负责调度,不应该知道业务细节
  • 灵活性:同一个命令可以配合不同的接收者使用(如"保存"命令可以保存到文件或数据库)
  • 可测试性:便于单元测试时注入 Mock 接收者

下面是标准实现的代码示例,由客户端创建 Receiver 并注入到具体命令中:

java 复制代码
// 抽象命令类
public interface Command {
    public void execute();
}

// 具体命令类
public class ConcreteCommand implements Command {
    private Receiver receiver;

    public ConcreteCommand(Receiver receiver) {
        this.receiver = receiver;
    }

    public void execute() {
        receiver.action();
    }
}

// 接收者
public class Receiver {
    public void action() {
        System.out.println("Receiver action executed");
    }
}

// 调用者
public class Invoker {
    private Command command;

    public void setCommand(Command command) {
        this.command = command;
    }

    public void executeCommand() {
        command.execute();
    }
}

// 客户端代码
Receiver receiver = new Receiver();
Command command = new ConcreteCommand(receiver);
Invoker invoker = new Invoker();

invoker.setCommand(command);
invoker.executeCommand();

⚠️ 反模式:如果由调用者(Invoker)创建接收者,会导致调用者与具体业务耦合,违背了命令模式的初衷。正确做法是由客户端创建 Receiver 并注入到具体命令中。

命令队列(延迟执行)

命令队列用于延迟执行场景------命令先入队,之后统一执行。核心原则:队列管"还没做的" ,入队的命令尚未执行,从队列移除只是取消,不需要回滚。

java 复制代码
// Command 接口只需 execute()
public interface Command {
    public void execute();
}

// 命令队列:管理未执行的命令
class CommandQueue {
    private Deque<Command> queue = new ArrayDeque<>();

    public void addCommand(Command command) {
        queue.offer(command);
    }

    public void executeAll() {
        while (!queue.isEmpty()) {
            queue.poll().execute();
        }
    }

    public void cancelLast() {
        // 从队尾移除最后一个未执行的命令(还没执行,所以是取消,不是撤销)
        if (!queue.isEmpty()) {
            queue.pollLast();
        }
    }
}

撤销栈(执行后可回滚)

撤销栈用于需要回滚的场景------命令先执行,再压入历史栈,需要时可以撤销。核心原则:撤销栈管"已经做的" ,每个命令必须实现 undo() 方法以支持回滚。

java 复制代码
// Command 接口需同时声明 execute() 和 undo()
public interface UndoableCommand {
    public void execute();
    public void undo();
}

// 撤销栈:管理已执行的命令,支持回滚
class CommandHistory {
    private Deque<UndoableCommand> history = new ArrayDeque<>();

    public void execute(UndoableCommand command) {
        command.execute();
        history.push(command);
    }

    public void undo() {
        if (!history.isEmpty()) {
            history.pop().undo();
        }
    }
}

一句话区分:队列管"还没做的"(取消即可),撤销栈管"已经做的"(需要回滚) 。不要把两者混在一个类里。

常见反模式

以下反模式基于一个典型场景展开:点餐系统中,用户依次添加订单命令到队列(如"奶茶"→"咖啡"→"果汁"),Cancel 是取消队列中最后一个未执行的命令(从队列移除),Confirm 是确认并执行队列中所有命令(按顺序制作)。Cancel 和 Confirm 都是对队列本身的管理操作。

反模式一:不是所有操作都适合做成 Command

Cancel 和 Confirm 是 Invoker 对队列的管理操作,属于 Invoker 的职责,不应设计为 Command。只有需要被"存储、排队、撤销"的业务操作才是 Command。

java 复制代码
// ❌ 错误:CancelCommand 放进队列,confirm 时会"执行"它------毫无意义
q.addLast(new OrderCommand("MilkTea"));
q.addLast(new CancelCommand());  // confirm 时执行 CancelCommand?语义矛盾

// ✅ 正确:Cancel 是 Invoker 提供的能力,直接操作队列
q.removeLast();  // 从队列移除,不涉及任何 Command 执行

反模式二:只有一种 Receiver 时,Receiver 是多余的一层

Receiver 的价值在于同一个 Command 接口,注入不同 Receiver 产生不同行为 (如 SaveCommand 可以注入 FileReceiver 存文件,也可以注入 DbReceiver 存数据库)。如果只有一种 Receiver,去掉它直接在 Command 里写逻辑没有区别,反而增加了一个无意义的间接层。

总结

命令模式的本质是把请求封装成对象,让请求可以被存储、排队、撤销、组合------发送者和接收者完全解耦,命令对象成为两者之间的桥梁。

什么时候用

  • 系统需要支持撤销/重做功能
  • 需要将操作排队、延迟执行或批处理
  • 需要记录操作日志以便审计或恢复
  • 需要支持事务性操作(多个操作要么全部成功,要么全部回滚)
  • 发送者和接收者需要解耦

什么时候不用

  • 简单的请求调用,不需要撤销、排队、日志等功能
  • 只有一种 Receiver,Receiver 层没有存在的必要
  • 操作不需要被存储或传递

简单记忆

命令模式解决"把请求变成对象"的问题,让操作可以存储、排队、撤销、组合。

⚠️ 用命令模式要注意:每个具体命令都需要一个类,类数量会爆炸;如果命令需要支持撤销,需要维护命令执行前的状态,增加了实现复杂度。

相似模式区分

命令模式容易和策略、状态模式混淆,它们都涉及"封装行为",但实现方式和意图不同。

总览对比:

模式 核心意图 典型场景
命令 将请求封装为对象,支持排队、撤销、日志 撤销重做、事务处理、任务队列
策略 定义算法族,使它们可以相互替换 折扣算法、支付方式、排序
状态 允许对象在状态改变时改变其行为 订单状态流转、审批流程

口诀:命令管请求,策略管算法,状态管行为。

命令 vs 策略

两者都涉及"封装可变行为",但意图完全不同。命令模式关注的是请求的管理 ------把请求变成对象,支持存储、排队、撤销;策略模式关注的是算法的替换------把算法封装成独立对象,运行时可互换。

维度 命令模式 策略模式
核心意图 将请求封装为对象,支持排队、撤销、日志 定义算法族,使它们可以相互替换
结构差异 包含 Command、Receiver、Invoker 包含 Strategy、Context
关注点 请求的封装和管理 算法的替换和扩展
典型场景 撤销重做、事务处理、任务队列 排序算法、支付方式、折扣策略

逐步区分法

  • 如果需要支持撤销、重做、排队 → 选择命令模式
  • 如果需要动态切换算法或策略 → 选择策略模式
  • 如果需要将请求发送者与接收者解耦 → 选择命令模式
  • 如果需要让算法独立于使用它的客户端变化 → 选择策略模式

简单记忆口诀:命令管请求,策略管算法。

命令 vs 状态

两者都涉及"封装行为",但实现方式和意图完全不同。命令模式中,命令对象是独立的,可以被存储、传递;状态模式中,状态对象依赖于 Context,状态之间可以触发转换。

维度 命令模式 状态模式
核心意图 将请求封装为对象 允许对象在状态改变时改变其行为
结构差异 Command 对象是独立的 State 对象依赖于 Context
关注点 请求的封装和管理 对象状态的转换和行为
典型场景 撤销重做、事务处理 订单状态、工作流状态

逐步区分法

  • 如果需要将请求封装为对象以便管理 → 选择命令模式
  • 如果对象行为随状态改变而改变 → 选择状态模式
  • 如果需要支持撤销和重做 → 选择命令模式
  • 如果需要管理对象的生命周期状态 → 选择状态模式

简单记忆口诀:命令封装请求,状态管理行为。

练习题目

自助点餐机

题目描述 :奶茶店的自助点餐机支持堂食和外卖两种订单。堂食订单由堂食接收者处理,输出 Dine-in: XXX is ready!;外卖订单由外卖接收者处理,输出 Takeout: XXX is ready!。点餐机支持点单、取消、确认三种操作。

输入描述:第一行是一个整数 n(1 ≤ n ≤ 100),表示操作的数量。接下来的 n 行,每行包含操作信息:

  • 点单操作:1 饮品名称 订单类型(D 表示堂食,T 表示外卖)
  • 取消操作:2
  • 确认操作:3

保证:取消操作时队列不为空;确认操作时队列不为空。

输出描述:每次确认操作时,按顺序输出队列中所有订单的制作情况。

输入示例

r 复制代码
8
1 MilkTea D
1 Coffee T
1 Cola D
1 Juice T
2
3
1 Coffee D
3

输出示例

vbnet 复制代码
Dine-in: MilkTea is ready!
Takeout: Coffee is ready!
Dine-in: Cola is ready!
Dine-in: Coffee is ready!

设计推演

看题目描述,提取关键信息:

  • 输入:点单操作(饮品名 + 堂食/外卖)、取消、确认
  • 输出:确认时按顺序输出所有订单的制作情况
  • 关键词:按顺序输出队列取消

订单不是点一个做一个,而是先攒着,确认时才统一执行------这是命令队列的典型场景。

识别角色:

  • CommandMakeCommand,封装"制作请求"(做什么饮品 + 谁来做)
  • ReceiverDineinReceiverTakeoutReceiver,真正执行制作的人
  • InvokerOrderSystem,只管命令队列(点单、取消、确认),不关心怎么做饮品

为什么 Invoker 不直接持有 Receiver?因为职责混乱、难以扩展。正确做法:客户端负责组装(决定谁做),Invoker 只负责调度(什么时候做),Command 负责绑定(把"谁做"和"做什么"绑在一起)。

解题思路:Command 是点单命令,Receiver 是做外卖的员工和做堂食的员工,Invoker 是点单系统。点单命令创建后加入队列,确认时按顺序执行(延迟执行,不是立即制作)。客户端负责创建 Receiver 实例并注入到具体命令中;Invoker 只管理命令队列,不关心具体业务逻辑。

java 复制代码
import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();

        // Client 创建 Receiver 实例
        Receiver dineIn = new DineinReceiver();
        Receiver takeout = new TakeoutReceiver();

        // Invoker 只管命令队列
        OrderSystem os = new OrderSystem();

        while (n-- > 0) {
            int op = sc.nextInt();
            if (op == 1) {
                String name = sc.next();
                String type = sc.next();

                // Client 创建 Command 并指定 Receiver
                Receiver r = "D".equals(type) ? dineIn : takeout;
                Command cmd = new MakeCommand(name, r);
                os.order(cmd);
            } else if (op == 2) {
                os.cancel();
            } else {
                os.confirm();
            }
        }
    }
}

// Command:声明接口
interface Command {
    public void execute();
}

// ConcreteCommand:绑定 Receiver 和动作
class MakeCommand implements Command {
    private String drinkName;
    private Receiver receiver;

    public MakeCommand(String drinkName, Receiver receiver) {
        this.drinkName = drinkName;
        this.receiver = receiver;
    }

    public void execute() {
        receiver.make(drinkName);
    }
}

// Invoker:只管理命令队列,只和 Command 接口交互
class OrderSystem {
    private Deque<Command> q = new ArrayDeque<>();

    public void order(Command command) {
        q.addLast(command);
    }

    public void cancel() {
        q.removeLast();
    }

    public void confirm() {
        while (!q.isEmpty()) {
            q.pollFirst().execute();
        }
    }
}

// Receiver 接口
interface Receiver {
    public void make(String name);
}

// ConcreteReceiver
class DineinReceiver implements Receiver {
    public void make(String name) {
        System.out.println("Dine-in: " + name + " is ready!");
    }
}

class TakeoutReceiver implements Receiver {
    public void make(String name) {
        System.out.println("Takeout: " + name + " is ready!");
    }
}

扩展:实际项目中的命令模式

撤销/重做(文本编辑器)

文本编辑器需要支持撤销操作,命令模式可以将每个操作封装为命令对象,通过记录命令历史实现撤销和重做。

java 复制代码
// 抽象命令:支持撤销
public interface UndoableCommand {
    public void execute();
    public void undo();
}

// 具体命令:插入文本
public class InsertTextCommand implements UndoableCommand {
    private TextEditor editor;
    private String text;
    private int position;

    public InsertTextCommand(TextEditor editor, String text, int position) {
        this.editor = editor;
        this.text = text;
        this.position = position;
    }

    public void execute() {
        editor.insertText(text, position);
    }

    public void undo() {
        editor.deleteText(position, position + text.length());
    }
}

// 调用者:编辑器控制器(维护命令历史)
public class EditorController {
    private List<UndoableCommand> history = new ArrayList<>();
    private int currentCommandIndex = -1;

    public void executeCommand(UndoableCommand command) {
        command.execute();
        history = new ArrayList<>(history.subList(0, currentCommandIndex + 1));
        history.add(command);
        currentCommandIndex++;
    }

    public void undo() {
        if (currentCommandIndex >= 0) {
            history.get(currentCommandIndex).undo();
            currentCommandIndex--;
        }
    }

    public void redo() {
        if (currentCommandIndex < history.size() - 1) {
            currentCommandIndex++;
            history.get(currentCommandIndex).execute();
        }
    }
}

关键点:插入命令撤销时执行反操作(删除)即可;命令历史列表支持重做功能,需要维护当前命令索引;执行新命令时需要清除索引之后的所有命令。

类似的场景:数据库事务管理 (转账时扣款和加款要么都成功,要么都回滚)、图形编辑器的宏命令(多个操作组合成一个命令,撤销时逆序撤销所有子命令)。

命令队列(任务调度)

在电商系统中,订单处理需要执行多个步骤(库存扣减、支付处理、物流通知等),这些步骤可以异步执行。命令模式可以将每个步骤封装为命令对象,由任务队列统一调度。

java 复制代码
// 抽象命令
public interface TaskCommand {
    public void execute();
    public String getTaskName();
}

// 具体命令:库存扣减
public class ReduceInventoryCommand implements TaskCommand {
    private InventoryService inventoryService;
    private String productId;
    private int quantity;

    public ReduceInventoryCommand(InventoryService inventoryService, String productId, int quantity) {
        this.inventoryService = inventoryService;
        this.productId = productId;
        this.quantity = quantity;
    }

    public void execute() {
        inventoryService.reduce(productId, quantity);
    }

    public String getTaskName() {
        return "Reduce inventory for product: " + productId;
    }
}

// 调用者:任务队列(异步执行)
public class TaskQueue {
    private Queue<TaskCommand> queue = new LinkedList<>();
    private ExecutorService executor = Executors.newFixedThreadPool(3);

    public void addTask(TaskCommand command) {
        queue.offer(command);
    }

    public void processTasks() {
        while (!queue.isEmpty()) {
            TaskCommand command = queue.poll();
            executor.submit(() -> {
                try {
                    command.execute();
                    System.out.println("Task completed: " + command.getTaskName());
                } catch (Exception e) {
                    System.err.println("Task failed: " + command.getTaskName());
                }
            });
        }
    }

    public void shutdown() {
        executor.shutdown();
    }
}

关键点:任务队列将命令存储在队列中,由线程池异步执行;每个命令都是独立的,可以并行执行;任务队列不关心具体业务逻辑,只负责调度执行。

类似的场景:GUI 按钮事件处理(按钮不直接调用文档方法,而是通过命令对象解耦,支持运行时动态更换按钮功能)。

技术交流 & 更多原创内容,关注公众号:咖啡八杯

相关推荐
AI人工智能_电脑小能手1 小时前
【大白话说Java面试题 第125题】【并发篇】第25题:说说 Java 线程的中断机制
java·后端·面试
Java内核笔记1 小时前
Spring Security 源码解析(六)无状态 JWT 实践:Session 共享与自定义过滤器
java·后端
荣码1 小时前
LangGraph多Agent协作:3个Agent干活比1个强,但我踩了4个坑
java·python
candyTong2 小时前
阿里开源 AI Code Review 工具:ocr review 的执行链路解析
javascript·后端·架构
唐青枫3 小时前
Java 虚拟线程实战指南:从 Thread API 到 Spring Boot 高并发应用
java
doiito16 小时前
【Agent Harness】TPS的“自工程完结”教会了我一件事:别把Bug留给下一道工序
架构·rust
烬羽17 小时前
中英文 token 数量差一倍?两段 JS 代码搞懂 LLM 底层是怎么"读"文字的
javascript·程序员·架构
花椒技术17 小时前
HJPusher / HJPlayer SDK 实践:我们为什么把直播推播链路拆成一套可复用能力
设计模式·harmonyos·直播
白鲸开源19 小时前
Apache SeaTunnel Zeta Engine 的 Basic Auth 是怎么工作的?
java·vue.js·github