命令模式:请求封装、撤销重做、实战:事务回滚

命令模式,说白了就是把「请求」包装成一个对象。

你想想看,在代码里我们经常要调用某个方法。但方法调用是紧耦合的——调用方直接依赖了执行方。如果我想把请求排队、记录日志、或者支持撤销,那就麻烦了。

命令模式就是来解决这个问题的。它把「做什么」和「谁来做」彻底拆开。

为什么需要命令模式?

我在项目中遇到过这样一个场景:一个编辑器,用户点了「保存」按钮,然后想撤销。如果保存逻辑直接写在按钮点击事件里,撤销就无从谈起。

命令模式的做法是:每次操作都生成一个命令对象,里面包含了执行和撤销两个方法。按钮只管调用命令的 execute(),撤销时调用 undo()。

核心思想:将请求封装为对象,从而支持参数化、排队、日志、撤销等操作。

命令模式的结构

命令模式有四个角色,我习惯这样记:

  • Command(命令接口):声明 execute() 和 undo() 方法
  • ConcreteCommand(具体命令):实现命令接口,持有接收者引用
  • Receiver(接收者):真正干活的对象
  • Invoker(调用者):持有命令对象,触发执行

嗯,这里要注意:调用者不关心命令内部怎么实现,它只负责调用 execute()。

Invoker(调用者) Command(接口) ConcreteCommand Receiver(接收者) 持有 实现 调用

一个简单的例子:遥控器

先看一个最基础的实现。假设我们有个遥控器,可以控制灯的开和关。

// 命令接口
public interface Command {
    void execute();
    void undo();
}

// 接收者:灯
public class Light {
    public void on() { System.out.println("灯亮了"); }
    public void off() { System.out.println("灯灭了"); }
}

// 具体命令:开灯
public class LightOnCommand implements Command {
    private Light light;
    
    public LightOnCommand(Light light) {
        this.light = light;
    }
    
    @Override
    public void execute() {
        light.on();
    }
    
    @Override
    public void undo() {
        light.off();
    }
}

// 调用者:遥控器
public class RemoteControl {
    private Command command;
    
    public void setCommand(Command command) {
        this.command = command;
    }
    
    public void pressButton() {
        command.execute();
    }
    
    public void pressUndo() {
        command.undo();
    }
}

你看,遥控器根本不知道它控制的是灯还是风扇。它只认 Command 接口。

撤销与重做:核心难点

撤销重做,说白了就是「历史记录」的管理。

我习惯用一个栈来保存执行过的命令。撤销时从栈里弹出命令,调用它的 undo()。重做时再压回去。

我的经验:撤销栈的深度一定要有限制。我曾经见过一个系统,用户连续操作了上千次,结果撤销栈把内存撑爆了。建议限制在 50-100 步。
public class CommandHistory {
    private Stack<Command> undoStack = new Stack<>();
    private Stack<Command> redoStack = new Stack<>();
    private static final int MAX_HISTORY = 50;
    
    public void executeCommand(Command cmd) {
        cmd.execute();
        undoStack.push(cmd);
        redoStack.clear(); // 新操作清空重做栈
        
        if (undoStack.size() > MAX_HISTORY) {
            undoStack.remove(0); // 移除最旧的
        }
    }
    
    public void undo() {
        if (undoStack.isEmpty()) return;
        Command cmd = undoStack.pop();
        cmd.undo();
        redoStack.push(cmd);
    }
    
    public void redo() {
        if (redoStack.isEmpty()) return;
        Command cmd = redoStack.pop();
        cmd.execute();
        undoStack.push(cmd);
    }
}

嗯,这里有个坑:撤销和重做必须成对出现。每次执行新命令时,一定要清空重做栈。否则用户会看到「重做」了一个已经被覆盖的操作,那逻辑就乱了。

实战:事务回滚

事务回滚是命令模式最经典的应用之一。我在做支付系统时就用过这个思路。

假设一个转账操作:需要扣减账户A的余额,增加账户B的余额。如果第二步失败了,第一步必须回滚。

// 数据库操作命令
public class TransferCommand implements Command {
    private Account fromAccount;
    private Account toAccount;
    private double amount;
    private boolean executed = false;
    
    public TransferCommand(Account from, Account to, double amount) {
        this.fromAccount = from;
        this.toAccount = to;
        this.amount = amount;
    }
    
    @Override
    public void execute() {
        if (executed) return;
        fromAccount.debit(amount);  // 扣款
        toAccount.credit(amount);   // 加款
        executed = true;
    }
    
    @Override
    public void undo() {
        if (!executed) return;
        toAccount.debit(amount);    // 回滚:从B扣回
        fromAccount.credit(amount); // 回滚:给A加回
        executed = false;
    }
}

// 事务管理器
public class TransactionManager {
    private List<Command> commands = new ArrayList<>();
    
    public void addCommand(Command cmd) {
        commands.add(cmd);
    }
    
    public void executeAll() {
        List<Command> executed = new ArrayList<>();
        try {
            for (Command cmd : commands) {
                cmd.execute();
                executed.add(cmd);
            }
        } catch (Exception e) {
            // 回滚所有已执行的命令
            System.out.println("事务失败,开始回滚...");
            Collections.reverse(executed);
            for (Command cmd : executed) {
                cmd.undo();
            }
            throw new RuntimeException("事务回滚完成", e);
        }
    }
}
我曾经踩过的坑:回滚顺序必须与执行顺序相反。如果先执行了A再执行B,回滚时必须先回滚B再回滚A。否则会出现数据不一致。

命令模式的高级用法:宏命令

宏命令就是一组命令的组合。你可以把多个命令打包成一个命令来执行。

public class MacroCommand implements Command {
    private List<Command> commands;
    
    public MacroCommand(List<Command> commands) {
        this.commands = commands;
    }
    
    @Override
    public void execute() {
        for (Command cmd : commands) {
            cmd.execute();
        }
    }
    
    @Override
    public void undo() {
        // 逆序撤销
        for (int i = commands.size() - 1; i >= 0; i--) {
            commands.get(i).undo();
        }
    }
}

宏命令在批量操作中特别有用。比如用户选中了10个文件,然后点击「删除」。这10个删除操作可以打包成一个宏命令,撤销时一次性恢复。

命令模式的优缺点

优点 缺点
解耦调用者和接收者 类数量会增多(每个操作一个类)
支持撤销/重做 命令对象可能占用较多内存
支持命令排队和日志 调试时不太直观(调用链变长)
可以组合成宏命令 需要额外管理命令生命周期

什么时候用命令模式?

我个人习惯在以下场景使用命令模式:

  • 需要支持撤销/重做功能(编辑器、绘图工具)
  • 需要将请求排队或记录日志(任务调度、审计)
  • 需要支持事务回滚(数据库操作、支付系统)
  • 需要将请求参数化(菜单系统、快捷键绑定)

说白了,只要你的系统里「操作」本身需要被当作对象来管理,命令模式就是最佳选择。

一句话总结:命令模式把「做什么」封装成对象,让调用者只关心「什么时候做」,不关心「谁来做」和「怎么做」。
公众号:蓝海资料掘金营,微信deep3321