命令模式:请求封装、撤销与重做

说实话,命令模式是我在实际项目中用得比较多的一个模式。为什么?因为它解决了一个很实在的问题——如何把「请求」变成「对象」

你想想看,在传统的调用方式里,我们直接调用某个方法,比如 button.click()。这没问题,但一旦你需要记录操作、撤销操作、或者把操作排队执行,直接调用就行不通了。这时候,命令模式就派上用场了。

什么是命令模式?

命令模式的核心思想很简单:把每一个操作封装成一个对象。这个对象包含了执行操作所需的所有信息——调用哪个对象、调用什么方法、传什么参数。

我习惯把它理解成「外卖订单」。你打电话给餐厅点餐,服务员把订单写下来,然后交给厨师。这个订单就是命令对象,它把「你要吃什么」这个请求封装起来了。厨师不需要知道你是谁,只需要按照订单做菜就行。

定义:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化、对请求排队或记录请求日志,以及支持可撤销的操作。

模式结构

命令模式涉及四个角色:

  • Command(抽象命令):声明执行操作的接口
  • ConcreteCommand(具体命令):实现抽象命令,持有接收者对象
  • Invoker(调用者):要求命令对象执行请求
  • Receiver(接收者):真正执行操作的对象
Client Invoker Command ConcreteCommand Receiver 持有命令对象 真正执行者

代码实现

我们用一个文本编辑器的撤销/重做功能来演示。这个例子我在项目中做过类似的,嗯,当时是做的一个在线文档编辑器。

// 1. 接收者 - 文本编辑器
class TextEditor {
    private StringBuilder content = new StringBuilder();
    
    public void append(String text) {
        content.append(text);
        System.out.println("当前内容: " + content.toString());
    }
    
    public void delete(int length) {
        if (length > content.length()) {
            length = content.length();
        }
        content.delete(content.length() - length, content.length());
        System.out.println("当前内容: " + content.toString());
    }
    
    public String getContent() {
        return content.toString();
    }
}

// 2. 抽象命令
interface Command {
    void execute();
    void undo();
}

// 3. 具体命令 - 追加文本
class AppendCommand implements Command {
    private TextEditor editor;
    private String text;
    
    public AppendCommand(TextEditor editor, String text) {
        this.editor = editor;
        this.text = text;
    }
    
    @Override
    public void execute() {
        editor.append(text);
    }
    
    @Override
    public void undo() {
        editor.delete(text.length());
    }
}

// 4. 调用者 - 命令管理器(支持撤销和重做)
class CommandManager {
    private Stack<Command> undoStack = new Stack<>();
    private Stack<Command> redoStack = new Stack<>();
    
    public void executeCommand(Command cmd) {
        cmd.execute();
        undoStack.push(cmd);
        redoStack.clear(); // 执行新命令后,清空重做栈
    }
    
    public void undo() {
        if (!undoStack.isEmpty()) {
            Command cmd = undoStack.pop();
            cmd.undo();
            redoStack.push(cmd);
        } else {
            System.out.println("没有可撤销的操作");
        }
    }
    
    public void redo() {
        if (!redoStack.isEmpty()) {
            Command cmd = redoStack.pop();
            cmd.execute();
            undoStack.push(cmd);
        } else {
            System.out.println("没有可重做的操作");
        }
    }
}

// 5. 客户端使用
public class Client {
    public static void main(String[] args) {
        TextEditor editor = new TextEditor();
        CommandManager manager = new CommandManager();
        
        // 执行一系列操作
        manager.executeCommand(new AppendCommand(editor, "Hello "));
        manager.executeCommand(new AppendCommand(editor, "World!"));
        manager.executeCommand(new AppendCommand(editor, " 你好"));
        
        System.out.println("--- 撤销两次 ---");
        manager.undo();
        manager.undo();
        
        System.out.println("--- 重做一次 ---");
        manager.redo();
    }
}

运行结果:

当前内容: Hello 
当前内容: Hello World!
当前内容: Hello World! 你好
--- 撤销两次 ---
当前内容: Hello World!
当前内容: Hello 
--- 重做一次 ---
当前内容: Hello World!

命令模式的优点

优点 说明
解耦调用者和接收者 调用者不需要知道谁在执行操作
支持撤销/重做 通过保存命令历史实现
支持命令组合 宏命令可以组合多个子命令
支持延迟执行 命令可以排队、调度、异步执行

实际应用场景

我在项目中遇到过几个典型的应用场景:

  • GUI按钮和菜单:每个按钮对应一个命令对象,点击时执行
  • 事务操作:数据库操作可以封装成命令,支持回滚
  • 任务队列:把命令放入队列,由工作线程依次执行
  • 日志记录:记录所有操作命令,用于审计或恢复

我的习惯:在设计命令模式时,我会把命令对象设计成可序列化的。这样可以把命令持久化到磁盘,系统重启后还能恢复操作历史。这在做金融交易系统时特别有用。

命令模式的变体

除了基本的撤销/重做,命令模式还有一些实用的变体:

  • 宏命令:一个命令包含多个子命令,执行时依次调用
  • 延迟命令:命令创建后不立即执行,而是等待某个条件触发
  • 事务命令:一组命令要么全部成功,要么全部回滚

我曾经踩过的坑:有一次我设计命令模式时,把接收者的状态也保存在命令对象里。结果命令对象变得非常庞大,内存消耗严重。后来我改成只保存操作参数,接收者的状态由接收者自己管理。记住:命令对象应该轻量,只保存「怎么做」的信息,不保存「做之前的状态」

与策略模式的区别

很多人容易把命令模式和策略模式搞混。我简单说一下区别:

  • 命令模式:关注的是「把操作封装成对象」,重点是请求的封装、排队、撤销
  • 策略模式:关注的是「算法的封装和替换」,重点是不同算法的切换

说白了,命令模式是对「动作」的封装,策略模式是对「算法」的封装。一个命令通常只做一件事,而策略可以包含复杂的业务逻辑。

总结

命令模式是我个人非常喜欢的一个模式。它把「请求」这个抽象概念变成了实实在在的对象,让我们可以对请求进行各种操作——记录、排队、撤销、重做。

在实际项目中,我建议你从最简单的场景开始用。比如先实现一个撤销功能,等需要重做时再扩展。不要一开始就把命令模式设计得太复杂,否则容易过度设计。

记住:命令模式的核心价值在于「请求的封装」。当你需要把操作当作数据来处理时,就该考虑它了。


公众号:蓝海资料掘金营,微信deep3321