设计模式(十八)命令模式 —— 将操作封装成对象,实现撤销、队列等扩展

"请求即对象,行为可编程。"

在现代软件系统中,我们常常需要支持撤销/重做 、将操作排队执行 、实现事务回滚 ,或解耦调用者与执行者。若直接通过方法调用或硬编码逻辑,这些需求将导致代码高度耦合、难以扩展、无法动态组合。

命令模式(Command Pattern) 的核心思想是:将一个请求(操作)封装为一个独立的对象 ,使其具备可存储、可传递、可撤销、可组合的能力。

本文将从原理到实践,深入剖析命令模式在 Java 中的工业级应用。


🧠 一、命令模式的本质:请求的对象化

命令模式源于对"行为"的抽象。传统面向对象编程中,行为通常依附于对象的方法;而命令模式则将行为本身提升为对象------即"动作"可以被创建、传递、保存甚至撤销。

这种设计打破了"调用者必须知道接收者及其方法"的限制,使系统结构更灵活、模块间耦合更低。本质上,命令模式实现了控制反转(Inversion of Control):调用者不再直接控制执行逻辑,而是委托给命令对象。

其哲学基础是:行为也是一种状态。当我们将"做什么"和"怎么做"分离,并把前者封装为对象,就获得了前所未有的组合能力。


🔗 二、核心角色与职责划分

命令模式包含四个关键角色,它们共同构成一个松耦合的请求处理机制:

  • Command(命令接口) :声明执行方法(如 execute()),定义命令的统一契约。
  • ConcreteCommand(具体命令) :实现 Command 接口,持有对 Receiver 的引用,并在 execute() 中调用 Receiver 的具体方法。
  • Receiver(接收者):真正执行业务逻辑的对象,对命令模式"无感"------它不知道自己被哪个命令调用。
  • Invoker(调用者):持有命令对象,负责触发命令执行,但不关心命令的具体内容。

这种结构使得 Invoker 与 Receiver 完全解耦。例如,一个按钮(Invoker)只需持有一个 Command,无需知道背后是保存文件、发送邮件还是格式化文本。
uses holds Command +execute() ConcreteCommand -receiver: Receiver +execute() Receiver +action() Invoker -command: Command +click()


⚙️ 三、基础实现:从方法调用到命令对象

考虑一个简单的文本编辑器场景:用户点击"加粗"按钮,文本变为粗体。若采用传统方式:

java 复制代码
boldButton.addActionListener(e -> editor.setBold(true));

此代码将 UI 与业务逻辑紧耦合。一旦需求变化(如支持撤销),修改成本极高。

使用命令模式重构:

java 复制代码
public interface Command {
    void execute();
}

public class BoldCommand implements Command {
    private final TextEditor editor;

    public BoldCommand(TextEditor editor) {
        this.editor = editor;
    }

    @Override
    public void execute() {
        editor.setBold(true);
    }
}

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

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

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

此时,按钮只依赖 Command 接口,可轻松替换为 ItalicCommandSaveCommand 等,实现高内聚低耦合。


↪️ 四、支持撤销:引入 Undo 机制

命令模式的强大之处在于天然支持撤销。只需在 Command 中增加 undo() 方法,并由具体命令保存执行前的状态。

为此,可定义扩展接口:

java 复制代码
public interface UndoableCommand extends Command {
    void undo();
}

以加粗命令为例:

java 复制代码
public class BoldCommand implements UndoableCommand {
    private final TextEditor editor;
    private final boolean wasBold; // 执行前状态快照

    public BoldCommand(TextEditor editor) {
        this.editor = editor;
        this.wasBold = editor.isBold();
    }

    @Override
    public void execute() {
        editor.setBold(true);
    }

    @Override
    public void undo() {
        editor.setBold(wasBold); // 恢复原状
    }
}

💡 关键原则:撤销不是"反向操作",而是"状态回滚"。因此必须在执行前捕获上下文。


📜 五、命令历史管理:Undo/Redo 栈

为了支持连续撤销与重做,需引入命令历史管理器:

java 复制代码
public class CommandHistory {
    private final Deque<UndoableCommand> undoStack = new ArrayDeque<>();
    private final Deque<UndoableCommand> redoStack = new ArrayDeque<>();

    public void execute(UndoableCommand cmd) {
        cmd.execute();
        undoStack.push(cmd);
        redoStack.clear(); // 新操作使重做栈失效
    }

    public void undo() {
        if (!undoStack.isEmpty()) {
            UndoableCommand cmd = undoStack.pop();
            cmd.undo();
            redoStack.push(cmd);
        }
    }

    public void redo() {
        if (!redoStack.isEmpty()) {
            UndoableCommand cmd = redoStack.pop();
            cmd.execute();
            undoStack.push(cmd);
        }
    }
}

该设计确保了操作的线性历史,且重做仅在撤销后有效,符合用户直觉。


📦 六、宏命令:批量操作的组合

宏命令(Macro Command)允许将多个命令组合为一个逻辑单元,实现"一键执行多步"。

java 复制代码
public class MacroCommand implements UndoableCommand {
    private final List<UndoableCommand> commands = new ArrayList<>();

    public void add(UndoableCommand cmd) {
        commands.add(cmd);
    }

    @Override
    public void execute() {
        commands.forEach(UndoableCommand::execute);
    }

    @Override
    public void undo() {
        // 撤销必须逆序
        for (int i = commands.size() - 1; i >= 0; i--) {
            commands.get(i).undo();
        }
    }
}

例如,用户可录制一个"格式化段落"宏:加粗 + 斜体 + 居中。执行一次即完成全部操作,撤销一次也全部回退。


⏳ 七、异步命令:非阻塞执行模型

在 Java 中,可通过 CompletableFuture 实现异步命令,适用于 I/O 密集型任务(如文件保存、网络请求):

java 复制代码
public abstract class AsyncCommand implements Command {
    protected CompletableFuture<Void> future = CompletableFuture.completedFuture(null);

    @Override
    public final void execute() {
        this.future = CompletableFuture.runAsync(this::doExecute);
    }

    protected abstract void doExecute();

    public CompletableFuture<Void> getFuture() {
        return future;
    }
}

使用时:

java 复制代码
AsyncCommand saveCmd = new AsyncCommand() {
    @Override
    protected void doExecute() {
        fileService.save(document);
    }
};
saveCmd.execute();
saveCmd.getFuture().thenRun(() -> ui.showMessage("Saved!"));

⚠️ 注意:异步命令的撤销需额外设计(如版本控制、幂等删除),避免竞态条件。


🖥️ 八、GUI 应用实战:可撤销的编辑器

在 Swing 或 JavaFX 中,命令模式是构建可撤销 UI 的标准方案。以 JTextArea 为例:

java 复制代码
class ReplaceTextCommand implements UndoableCommand {
    private final JTextArea area;
    private final String oldText, newText;
    private final int start, end;

    public ReplaceTextCommand(JTextArea a, String nt) {
        this.area = a;
        this.newText = nt;
        this.start = a.getSelectionStart();
        this.end = a.getSelectionEnd();
        this.oldText = a.getText().substring(start, end);
    }

    @Override
    public void execute() {
        area.replaceRange(newText, start, end);
    }

    @Override
    public void undo() {
        area.replaceRange(oldText, start, start + newText.length());
    }
}

用户选中文本 → 点击"加粗" → 自动包裹 <b> 标签 → 支持完整 Undo/Redo。整个过程无需 UI 层了解文本处理细节。


🔐 九、安全与审计:命令的增强包装

命令对象可作为横切关注点的载体。例如,在执行敏感操作前进行权限校验:

java 复制代码
public class SecureCommand implements UndoableCommand {
    private final UndoableCommand delegate;
    private final User user;
    private final String requiredRole;

    public SecureCommand(UndoableCommand cmd, User u, String role) {
        this.delegate = cmd;
        this.user = u;
        this.requiredRole = role;
    }

    @Override
    public void execute() {
        if (!user.hasRole(requiredRole)) {
            throw new SecurityException("Access denied");
        }
        AuditLog.record(user.getId(), delegate.getClass().getSimpleName());
        delegate.execute();
    }

    @Override
    public void undo() {
        delegate.undo();
    }
}

这种装饰器风格的包装,使安全与审计逻辑与业务命令正交,符合开闭原则。


📊 十、性能考量与适用边界

命令模式并非万能。其优势在以下场景显著:

  • 需要操作历史(Undo/Redo)
  • 需要任务队列或调度
  • 需要将请求参数化(如菜单项绑定不同命令)
  • 需要事务性操作(全部成功或全部回滚)

但在以下情况应谨慎使用:

  • 高频调用路径:如游戏主循环中的每帧更新,命令对象的创建开销可能成为瓶颈。
  • 简单回调:若无需撤销、队列或日志,直接使用 Lambda 表达式更简洁。
  • 内存敏感环境:命令对象若持有大对象引用(如整个文档),可能导致内存泄漏。

✅ 建议:对高频路径使用函数式接口(如 Runnable),对复杂交互使用命令模式。


🧪 十一、可测试性:命令天然适合单元测试

由于命令封装了完整的行为上下文,其单元测试极为直观:

java 复制代码
@Test
void boldCommand_undo_restores_original_state() {
    TextEditor editor = new TextEditor();
    editor.setBold(false);
    
    BoldCommand cmd = new BoldCommand(editor);
    cmd.execute();
    assertTrue(editor.isBold());
    
    cmd.undo();
    assertFalse(editor.isBold());
}

每个命令都是一个独立的测试单元,无需启动 GUI 或模拟复杂环境。


🌐 十二、与 Spring 等框架集成

在 Spring 应用中,命令可作为 Prototype Bean 动态注入:

java 复制代码
@Component
@Scope("prototype")
public class SendEmailCommand implements UndoableCommand {
    private final EmailService service;
    private final String to;
    private boolean sent = false;

    public SendEmailCommand(EmailService s, String recipient) {
        this.service = s;
        this.to = recipient;
    }

    @Override
    public void execute() {
        service.send(to, "Hello");
        sent = true;
    }

    @Override
    public void undo() {
        if (sent) service.recall(to); // 假设有撤回 API
    }
}

通过 ApplicationContext.getBean() 获取新实例,确保每次操作独立,避免状态污染。


🛑 十三、常见反模式与陷阱

  1. 过度封装:仅为打印日志创建命令,违背 YAGNI 原则。
  2. 状态快照不完整:撤销失败常因未保存足够上下文(如忽略光标位置)。
  3. 异步撤销竞态:多次快速操作后撤销,可能误删最新数据。
  4. 内存泄漏:命令长期持有大对象(如图像、文档),执行后未释放。

✅ 最佳实践:命令执行后主动置空大引用;撤销逻辑必须经过充分测试。


🎯 十四、结语:命令是行为的容器

命令模式远不止于"封装方法调用"。它是将行为提升为一等公民的设计哲学:

  • 可存储 → 实现操作历史;
  • 可传递 → 解耦调用者与执行者;
  • 可组合 → 构建复杂工作流;
  • 可撤销 → 提升用户体验;
  • 可审计 → 满足合规要求。

无论是在桌面应用、Web 后端,还是自动化测试框架中,命令模式都持续发挥着不可替代的作用。

下次当你需要"记住用户做了什么,并能回退"时,请记住:
不是你在调用方法,而是在发出命令。`;

相关推荐
触底反弹1 小时前
🤯 面试被问 AI Workflow 和 Agent 有啥区别?3 张图 + 2 段代码讲清楚!
人工智能·设计模式·面试
杨充1 天前
10.可测试性实战设计
设计模式·开源·代码规范
杨充1 天前
9.重构十二式的实战
设计模式·开源·代码规范
杨充1 天前
6.设计原则的全景图
设计模式·开源·全栈
杨充1 天前
2.面向对象的特性
设计模式
杨充1 天前
7.SOLID原则案例汇
设计模式·开源·全栈
杨充1 天前
8.反模式与坏味道
设计模式·开源·代码规范
杨充1 天前
3.接口vs抽象类比较
设计模式
咖啡八杯1 天前
文法、BNF与AST
java·设计模式·解释器模式·ast·文法