设计模式预计阅读 18 分钟7080 字2089 次浏览

设计模式修炼之路 ⑱ 命令模式——把请求封装成对象

把请求封装成对象、增量式撤销、白送的排队与重放、Executor 的铁证

目录

行为型第五站。

上一篇结尾说:责任链传的"请求"始终只是个参数,命令模式更进一步——把请求本身变成对象。这一篇兑现承诺。

场景就从最日常的编辑器讲起。你写文档时手一抖删了一段,按 Ctrl+Z,内容回来了;再按 Ctrl+Y,又删掉了。撤销、重做、任务队列、宏录制……这些功能的第一块基石,都是命令模式。

一、命令模式是什么

GoF 的原始定义:

Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations. (将一个请求封装成一个对象,从而使你可以用不同的请求对客户进行参数化;可以对请求排队、记录日志,以及支持可撤销的操作。)

翻译成人话:**把"要干的事"打包成对象。**调用方手里拿的不再是"干这件事"的过程调用,而是"干这件事"这个对象本身——而对象能存储、能传递、能排队、能记账、能反悔。

四个角色:

角色 干什么 本文对应
Command(抽象命令) 定义执行与撤销的统一接口 Command
ConcreteCommand(具体命令) 绑定接收者,实现"怎么执行、怎么撤销" InsertCommand / DeleteCommand
Receiver(接收者) 真正干活的对象 TextEditor
Invoker(调用者) 持有命令、负责执行、记录历史 CommandHistory

最好的类比是餐厅点单:你(客户端)不冲进后厨喊"来份宫保鸡丁"(直接方法调用),而是把需求写在订单上(命令对象)交给服务员(调用者)。服务员拿着订单能干的事就多了:立刻递后厨(执行)、压单等位(排队)、你说错了撤单(撤销)、月底翻存根对账(日志)。订单本身不会炒菜——真正炒菜的是后厨(接收者),订单只是把"要干什么"固化成了可搬运的纸片。

二、优点与缺点

优点:

  1. 请求与执行解耦。调用者只认识 Command 接口,根本不知道接收者是谁、操作怎么落地——命令对象把这一切打包带走了。
  2. 撤销是白送的。命令知道自己"干了什么",就有能力描述"怎么反着干"。调用者只管把命令压进历史栈,撤销时弹出来调 undo 即可。
  3. 排队、延迟、重试都是白送的。命令是对象,对象就能进队列。进队列之后什么时候执行、在哪个线程执行、失败了几时重试,全是调度层的自由。
  4. 可组合。一组命令可以拼成一个宏命令(组合模式 ⑨ 的老朋友),一键回放。

缺点:

  1. 类数量膨胀。每个操作一个命令类,操作多了类也多——Java 8 之前写起来相当啰嗦(lambda 缓解了大半,见第五节)。
  2. 间接层带来的理解成本。看代码时找不到直接调用,执行逻辑藏在命令对象的 execute 里,追起来要绕一层。
  3. 撤销的实现并不总是轻巧。多数操作可以定义逆操作,但"发邮件"这种不可逆操作,撤销只能语义补偿(再发一封道歉邮件?),设计时要掂量。

三、什么时候用

判断标准:"请求"需要在时间或空间上离开"发起者"——要排队、要延迟执行、要事后撤销、要记录重放,或者要跨线程跨进程搬运。

  • 撤销/重做:编辑器、绘图工具、数据库事务——本文场景
  • 任务队列:线程池、消息队列、定时任务,队列里排的一个个任务全是命令对象
  • 操作日志与恢复:把命令序列化进日志,宕机后重放恢复现场
  • 宏录制:把用户一连串操作录成命令列表,一键回放
  • 跨进程分发:MQ 的消息体,本质是"生产者想让消费者干的活"的对象化

反面对照:请求发起后立刻执行、执行完就完、不需要历史——直接调用方法就好,别为了模式而模式。

四、实现方式:增量式撤销

4.1 反面教材:快照式撤销

撤销最容易想到的做法:每干一步,存一份全文快照,撤销就回滚到上一份。

public class BadEditor {

    private String text = "";
    // 为了撤销,只能每一步都存一份全文快照
    private final Deque<String> snapshots = new ArrayDeque<>();

    public void insert(String s) {
        snapshots.push(text);
        text = text + s;
        System.out.println("  [编辑器] 插入 \"" + s + "\",当前:\"" + text + "\"");
    }

    public void delete(int count) {
        snapshots.push(text);
        text = text.substring(0, text.length() - count);
        System.out.println("  [编辑器] 删除 " + count + " 个字符,当前:\"" + text + "\"");
    }

    // 撤销 = 回滚到上一份快照
    // 文本是 String 还好;如果编辑的是 10MB 的文档对象,每一步存一份快照,内存直接爆炸
    // 而且想加"重做"、"宏录制"?全部要在这个类里继续堆代码
    public void undo() {
        if (snapshots.isEmpty()) {
            System.out.println("  [编辑器] 没有可撤销的操作");
            return;
        }
        text = snapshots.pop();
        System.out.println("  [编辑器] 撤销,当前:\"" + text + "\"");
    }
}

看着能跑,但病灶清晰:**撤销的单位是"整个文档",而不是"那一步操作"。**String 倒还扛得住,换成 10MB 的富文档对象,每敲一个字存一份快照,内存曲线直接起飞。更糟的是"撤销"这个能力被焊死在编辑器内部——想加重做、宏录制、操作日志,全得往这一个类里继续堆。

4.2 正解:每一步操作变成一个命令对象

第一步,命令接口——除了执行,撤销是命令模式的招牌能力:

public interface Command {
    void execute();
    void undo();
}

第二步,接收者。一个最朴素的文本编辑器:

public class TextEditor {
    private String text = "";

    public void insert(String s) { text = text + s; }
    public void delete(int count) { text = text.substring(0, text.length() - count); }
    public String getText() { return text; }
}

第三步,具体命令。插入命令的执行和撤销是一对完美互逆:

public class InsertCommand implements Command {

    private final TextEditor editor;
    private final String s;

    public InsertCommand(TextEditor editor, String s) {
        this.editor = editor;
        this.s = s;
    }

    @Override
    public void execute() {
        editor.insert(s);
        System.out.println("  [插入] \"" + s + "\",当前:\"" + editor.getText() + "\"");
    }

    @Override
    public void undo() {
        editor.delete(s.length());
        System.out.println("  [撤销插入] \"" + s + "\" 被移除,当前:\"" + editor.getText() + "\"");
    }
}

删除命令多一个心眼——执行时把删掉的内容记下来,不然撤销时拿什么还原?

public class DeleteCommand implements Command {

    private final TextEditor editor;
    private final int count;
    private String deleted;   // 执行时记下被删的内容,撤销时才能还原

    public DeleteCommand(TextEditor editor, int count) {
        this.editor = editor;
        this.count = count;
    }

    @Override
    public void execute() {
        String current = editor.getText();
        deleted = current.substring(current.length() - count);
        editor.delete(count);
        System.out.println("  [删除] 删掉 \"" + deleted + "\",当前:\"" + editor.getText() + "\"");
    }

    @Override
    public void undo() {
        editor.insert(deleted);
        System.out.println("  [撤销删除] \"" + deleted + "\" 回来了,当前:\"" + editor.getText() + "\"");
    }
}

第四步,调用者。它只认识 Command 接口,干的就三件事:执行、记账、反悔:

public class CommandHistory {

    private final Deque<Command> history = new ArrayDeque<>();

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

    public void undo() {
        if (history.isEmpty()) {
            System.out.println("  [历史] 没有可撤销的操作");
            return;
        }
        history.pop().undo();
    }
}

最后串起来,四条命令压栈,再连按五次撤销:

public class CommandTest {

    public static void main(String[] args) {
        System.out.println("========== 反面教材:快照式撤销 ==========");
        BadEditor bad = new BadEditor();
        bad.insert("设计模式");
        bad.delete(4);
        bad.insert("修炼之路");
        bad.undo();
        bad.undo();
        bad.undo();
        bad.undo();

        System.out.println();
        System.out.println("========== 正解:命令模式 ==========");
        TextEditor editor = new TextEditor();
        CommandHistory history = new CommandHistory();

        history.execute(new InsertCommand(editor, "设计"));
        history.execute(new InsertCommand(editor, "模式"));
        history.execute(new DeleteCommand(editor, 2));
        history.execute(new InsertCommand(editor, "修炼"));
        System.out.println("  当前文本:\"" + editor.getText() + "\"");

        System.out.println();
        System.out.println("-- 连按五次撤销,按执行逆序回放 --");
        history.undo();
        history.undo();
        history.undo();
        history.undo();
        history.undo();
        System.out.println("  当前文本:\"" + editor.getText() + "\"");
    }
}

运行输出:

========== 反面教材:快照式撤销 ==========
  [编辑器] 插入 "设计模式",当前:"设计模式"
  [编辑器] 删除 4 个字符,当前:""
  [编辑器] 插入 "修炼之路",当前:"修炼之路"
  [编辑器] 撤销,当前:""
  [编辑器] 撤销,当前:"设计模式"
  [编辑器] 撤销,当前:""
  [编辑器] 没有可撤销的操作

========== 正解:命令模式 ==========
  [插入] "设计",当前:"设计"
  [插入] "模式",当前:"设计模式"
  [删除] 删掉 "模式",当前:"设计"
  [插入] "修炼",当前:"设计修炼"
  当前文本:"设计修炼"

-- 连按五次撤销,按执行逆序回放 --
  [撤销插入] "修炼" 被移除,当前:"设计"
  [撤销删除] "模式" 回来了,当前:"设计模式"
  [撤销插入] "模式" 被移除,当前:"设计"
  [撤销插入] "设计" 被移除,当前:""
  [历史] 没有可撤销的操作
  当前文本:""

4.3 三处细节咂摸一下

**细节一:撤销的本质是"逆操作",不是"回滚"。**反面教材的撤销单位是整个文档(快照),命令模式的撤销单位是那一步操作本身(增量)。撤销一条插入只需删掉那几个字,撤销一条删除只需补回那几个字——文档 10MB 也好 100MB 也好,每条命令只携带自己的增量。Ctrl+Z 能做到瞬响应,靠的就是这个。

**细节二:命令对象是有状态的。**DeleteCommand 的 deleted 字段在执行那一刻才被填上——同一个命令对象,执行前和执行后携带的信息不一样。这一点和策略模式形成了鲜明对比(策略通常无状态),第六节展开。

**细节三:CommandHistory 完全不知道 TextEditor 的存在。**它只认识 Command 接口,账本里存的也全是接口引用。这意味着"历史记录"这个能力可以原封不动地搬到任何命令体系上:绘图软件的撤销栈、数据库的事务日志、游戏录像的回放器——换个命令实现,调用者一行不改。

4.4 白送的能力

把请求变成对象之后,很多东西是"白送"的——调用者已经持有命令对象了,剩下的只是用法问题:

  • 重做:undo 时别扔,压进另一个 redo 栈;Ctrl+Z 撤掉的 Ctrl+Y 再执行回来。
  • 宏录制:List<Command> 就是一个宏。再套一层 CompositeCommand(内部持有一组命令,execute 遍历执行、undo 逆序回滚)——组合模式 ⑨ 的经典复用。
  • 操作日志:execute 时把命令序列化进日志,系统宕机后按序重放,现场自动恢复——数据库 WAL、Redis AOF 的思路同源。
  • 排队与调度:命令对象扔进 BlockingQueue,什么时候执行、在哪个线程执行、失败了隔多久重试,全是调度层说了算——线程池干的就是这个买卖,第五节看实物。

五、在框架里找它

命令模式在 JDK 里有一个官方盖章的铁证——线程池的执行方法签名:

public interface Executor {
    void execute(Runnable command);
}

参数名就叫 command,JDK 自己承认了。Runnable 是命令接口,你的任务逻辑是命令对象,ThreadPoolExecutor 是调用者:它把命令对象排进队列(排队)、延迟调度(延迟执行)、必要时拒绝或重试。"把任务扔进线程池"这个每天写的动作,本质上和"把订单交给服务员"一模一样。

而且 JDK 在这里把命令接口的演化史也一并演示了。Java 8 之前提交一个任务得写匿名内部类,命令模式的"类爆炸"缺点被诟病多年;lambda 之后:

executor.submit(() -> orderService.close(orderId));

一行完事。函数式接口让命令模式从"重武器"退化成了日常空气——你天天在用,只是没意识到。

其他几处:

**Swing Action。**按钮和菜单项共用同一个 Action,点谁都是执行同一份命令——"工具栏的保存按钮"和"Ctrl+S"背后是同一个对象,启用禁用状态还能联动。GUI 领域命令模式的老前辈。

**Netty EventLoop。**每个 EventLoop 一条线程一个任务队列,普通任务和定时任务都是排队的命令对象——Netty 的定时任务能力就是"命令 + 优先级队列"的组合。

**MQ 消息。**生产者把"希望消费者干什么"序列化成消息体扔进队列,消费者取出来反序列化执行。这是命令模式的跨进程放大版:发起者和执行者不仅解耦,连进程、机器、时区都解耦了。事件驱动架构的底牌之一。

一句话总结这一节:凡是"请求要离开发起者独立旅行"的地方,命令模式都在场。

六、命令 vs 策略:结构上的孪生兄弟

写到这你可能后背发凉:接口 + 一堆实现类 + 一个持有者——这不就是 ⑮ 策略模式吗?长得确实几乎一样,区别全在灵魂上:

策略模式 ⑮ 命令模式 ⑱
打包的是什么 一种**算法**(怎么做) 一次**请求**(做什么)
生命周期 用完即弃,不关心历史 可存储、可排队、可撤销、可重放
有状态吗 通常无状态 通常有状态(执行时记增量,撤销时要还)
持有者持几个 Context 持**一个**,随时可换 Invoker 持**一摞**(历史、队列)
典型场景 促销算法、支付渠道 撤销栈、任务队列

分辨口诀:**策略回答"这事有几种做法",命令回答"这事能不能存起来反悔"。**同样是接口加实现类,策略是给调用方换算法的插槽,命令是给系统留的记账凭证。

写在最后

命令模式一句话总结:把请求封装成对象,执行与发起就此分离;对象能存能传能排队,撤销、重做、日志、重放全是白送。

下一篇 ⑲ 状态模式——换个场景,电商订单。"待支付"的订单点取消,直接关单退款都不用;"已发货"的订单点取消,只能走退货流程;同一个按钮,状态不同行为完全不同。如果用 if-else 判断状态,状态一多每个方法都是灾难现场。把每个状态做成类,状态一换行为整套跟着换——状态模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论