【西瓜带你学设计模式 | 第十八期 - 命令模式】命令模式 —— 请求封装与撤销实现、优缺点与适用场景

文章目录

    • 前言
    • [1. 命令模式是什么?](#1. 命令模式是什么?)
    • [2. 命令模式解决什么问题?](#2. 命令模式解决什么问题?)
    • [3. 核心结构](#3. 核心结构)
      • [3.1 Command(命令接口)](#3.1 Command(命令接口))
      • [3.2 ConcreteCommand(具体命令)](#3.2 ConcreteCommand(具体命令))
      • [3.3 Receiver(接收者)](#3.3 Receiver(接收者))
      • [3.4 Invoker(调用者)](#3.4 Invoker(调用者))
      • [3.5 Client(客户端)](#3.5 Client(客户端))
    • [4. 实现思路](#4. 实现思路)
    • [5. 示例](#5. 示例)
      • [5.1 Command:命令接口](#5.1 Command:命令接口)
      • [5.2 Receiver:接收者(灯)](#5.2 Receiver:接收者(灯))
      • [5.3 ConcreteCommand:具体命令](#5.3 ConcreteCommand:具体命令)
        • [5.3.1 开灯命令](#5.3.1 开灯命令)
        • [5.3.2 关灯命令](#5.3.2 关灯命令)
      • [5.4 Invoker:调用者(遥控器)](#5.4 Invoker:调用者(遥控器))
      • [5.5 Client:组装并使用](#5.5 Client:组装并使用)
    • [6. 宏命令(批量执行)](#6. 宏命令(批量执行))
    • [7. 优缺点](#7. 优缺点)
      • [7.1 优点](#7.1 优点)
      • [7.2 缺点](#7.2 缺点)
    • [8. 和其他模式怎么区分?](#8. 和其他模式怎么区分?)
      • [8.1 命令模式 vs 策略模式](#8.1 命令模式 vs 策略模式)
      • [8.2 命令模式 vs 责任链模式](#8.2 命令模式 vs 责任链模式)
      • [8.3 命令模式 vs 备忘录模式](#8.3 命令模式 vs 备忘录模式)
    • [9. 适用场景](#9. 适用场景)
    • [10. Java 中的实际应用](#10. Java 中的实际应用)
    • [11. 总结](#11. 总结)

前言

在日常开发中,我们经常遇到"调用方想执行一个操作,但不想(也不该)直接依赖执行方"的场景,比如:

  • 遥控器控制家电:按下按钮 → 开灯 / 关灯 / 调温度,遥控器不需要知道灯和空调的内部实现
  • 文本编辑器:用户点击"撤销" → 回退上一步操作,编辑器需要记住每一步做了什么
  • 任务队列:把多个操作排成队列,按顺序执行,甚至可以延迟执行
  • 事务系统:一组操作要么全部成功,要么全部回滚

这些场景有一个共同点:操作本身被当作"东西"来传递、存储、排队、撤销,而不是简单地直接调用一个方法。

命令模式(Command Pattern)要解决的核心就是:

将"请求"封装成一个对象,使得调用方和执行方彻底解耦;同时让请求可以被存储、排队、记录日志,以及支持撤销/重做。


1. 命令模式是什么?

命令模式是一种行为型设计模式,它把"做什么"这件事抽象成一个对象(命令对象),从而做到:

  • 调用方(Invoker)只管"发出命令",不关心谁来执行、怎么执行
  • 执行方(Receiver)只管"干活",不关心谁下达的命令
  • 命令对象是两者之间的桥梁,封装了"对谁做什么"的全部信息
  • 命令对象可以被存起来、排队、撤销、重做、组合

2. 命令模式解决什么问题?

  1. 调用方和执行方之间存在强耦合,调用方直接调用执行方的方法,改一个就得改另一个
  2. 需要支持**撤销(undo)/ 重做(redo)**操作
  3. 需要将操作排队执行延迟执行
  4. 需要记录操作日志,用于审计或系统恢复
  5. 需要将多个操作组合成一个宏命令,批量执行

如果发现代码里"发起操作的人"和"执行操作的人"绑得太死,或者需要对操作做撤销、排队、日志等额外处理,就很适合命令模式。


3. 核心结构

3.1 Command(命令接口)

声明执行操作的接口,通常包含 execute() 方法,需要撤销时还会有 undo() 方法。

3.2 ConcreteCommand(具体命令)

实现命令接口,内部持有一个 Receiver 的引用,execute() 里调用 Receiver 的具体方法。它是"请求"的载体,封装了"对谁做什么"。

3.3 Receiver(接收者)

真正干活的对象,知道如何执行具体的业务操作。命令对象只是"转发"请求给它。

3.4 Invoker(调用者)

持有命令对象,在合适的时机调用命令的 execute() 方法。它不知道命令具体做了什么,只管"触发"。

3.5 Client(客户端)

负责创建具体命令对象、设置接收者、把命令交给调用者。


4. 实现思路

  1. 定义 Command 接口(execute() + 可选的 undo()
  2. 写多个 ConcreteCommand(每个封装一个具体操作,内部持有 Receiver)
  3. Receiver(真正执行业务逻辑的类)
  4. Invoker(持有 Command 引用,负责触发执行)
  5. 客户端组装:创建 Receiver → 创建 Command 并绑定 Receiver → 把 Command 交给 Invoker

5. 示例

智能家居遥控器:通过遥控器控制灯光,支持开灯、关灯和撤销操作

5.1 Command:命令接口

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

5.2 Receiver:接收者(灯)

java 复制代码
public class Light {
    private String location;

    public Light(String location) {
        this.location = location;
    }

    public void on() {
        System.out.println(location + " 的灯已打开");
    }

    public void off() {
        System.out.println(location + " 的灯已关闭");
    }
}

5.3 ConcreteCommand:具体命令

5.3.1 开灯命令
java 复制代码
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(); // 开灯的撤销就是关灯
    }
}
5.3.2 关灯命令
java 复制代码
public class LightOffCommand implements Command {
    private Light light;

    public LightOffCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.off();
    }

    @Override
    public void undo() {
        light.on(); // 关灯的撤销就是开灯
    }
}

5.4 Invoker:调用者(遥控器)

java 复制代码
public class RemoteControl {
    private Command command;
    private Command lastCommand; // 记录上一次执行的命令,用于撤销

    public RemoteControl() {
        this.lastCommand = new NoCommand(); // 空命令,避免 null 判断
    }

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

    public void pressButton() {
        command.execute();
        lastCommand = command;
    }

    public void pressUndo() {
        System.out.println("--- 执行撤销 ---");
        lastCommand.undo();
    }

    // 空命令对象,什么都不做
    private static class NoCommand implements Command {
        @Override
        public void execute() { }

        @Override
        public void undo() { }
    }
}

5.5 Client:组装并使用

java 复制代码
public class Client {
    public static void main(String[] args) {
        // 创建接收者
        Light livingRoomLight = new Light("客厅");
        Light bedroomLight = new Light("卧室");

        // 创建命令
        Command livingRoomOn = new LightOnCommand(livingRoomLight);
        Command livingRoomOff = new LightOffCommand(livingRoomLight);
        Command bedroomOn = new LightOnCommand(bedroomLight);

        // 创建调用者(遥控器)
        RemoteControl remote = new RemoteControl();

        // 开客厅灯
        System.out.println("=== 开客厅灯 ===");
        remote.setCommand(livingRoomOn);
        remote.pressButton();

        // 关客厅灯
        System.out.println("=== 关客厅灯 ===");
        remote.setCommand(livingRoomOff);
        remote.pressButton();

        // 撤销上一步(关灯的撤销 = 开灯)
        remote.pressUndo();

        // 开卧室灯
        System.out.println("=== 开卧室灯 ===");
        remote.setCommand(bedroomOn);
        remote.pressButton();
    }
}

输出:

复制代码
=== 开客厅灯 ===
客厅 的灯已打开
=== 关客厅灯 ===
客厅 的灯已关闭
--- 执行撤销 ---
客厅 的灯已打开
=== 开卧室灯 ===
卧室 的灯已打开

会发现:

  • 遥控器(Invoker)完全不知道灯是怎么开关的,它只管调用 command.execute()
  • 灯(Receiver)也不知道是谁让它开关的,它只管执行自己的 on() / off()
  • 命令对象是两者之间的"中间人",封装了"对哪个灯做什么操作"
  • 撤销功能自然而然就有了------每个命令知道自己的反操作
  • 如果将来要新增"调节亮度"命令,只需新增一个 DimCommand 类,遥控器和灯的代码都不用改

6. 宏命令(批量执行)

命令模式还有一个很实用的变体------宏命令,可以把多个命令组合成一个,一键执行:

java 复制代码
public class MacroCommand implements Command {
    private Command[] commands;

    public MacroCommand(Command[] commands) {
        this.commands = commands;
    }

    @Override
    public void execute() {
        for (Command command : commands) {
            command.execute();
        }
    }

    @Override
    public void undo() {
        // 逆序撤销
        for (int i = commands.length - 1; i >= 0; i--) {
            commands[i].undo();
        }
    }
}

使用:

java 复制代码
// "回家模式":一键开客厅灯 + 开卧室灯
Command[] homeCommands = { livingRoomOn, bedroomOn };
Command homeMode = new MacroCommand(homeCommands);

remote.setCommand(homeMode);
remote.pressButton(); // 两个灯同时打开
remote.pressUndo();   // 两个灯同时关闭(逆序撤销)

7. 优缺点

7.1 优点

  1. 解耦调用方和执行方:Invoker 不依赖 Receiver,两者通过 Command 接口交互
  2. 支持撤销/重做:每个命令对象知道如何撤销自己,配合栈结构可以实现多级撤销
  3. 支持排队和延迟执行:命令是对象,可以放进队列、线程池、定时器
  4. 支持宏命令:多个命令可以组合成一个复合命令,批量执行
  5. 符合开闭原则:新增命令只需新增类,不改已有代码
  6. 支持日志和恢复:命令可以被序列化存储,用于审计或系统崩溃后恢复

7.2 缺点

  1. 类数量膨胀:每个操作都要写一个具体命令类,操作多了类会很多
  2. 简单场景过度设计:如果只是简单调用一个方法,用命令模式反而增加复杂度
  3. 性能开销:频繁创建命令对象可能带来额外的内存和 GC 压力

8. 和其他模式怎么区分?

8.1 命令模式 vs 策略模式

  • 策略模式:封装的是"算法",关注的是"用哪种方式做",客户端主动选择策略
  • 命令模式:封装的是"请求",关注的是"做什么",还支持撤销、排队、日志等额外能力

8.2 命令模式 vs 责任链模式

  • 责任链:请求沿链传递,由某个节点处理,强调"谁来处理"
  • 命令模式:请求被封装成对象,由 Invoker 触发执行,强调"请求本身的对象化"

8.3 命令模式 vs 备忘录模式

  • 备忘录模式:保存对象的状态快照,用于恢复状态
  • 命令模式:保存的是"操作"本身,通过 undo 反向执行来恢复。两者经常配合使用------备忘录保存命令执行前的状态,命令的 undo 利用备忘录来恢复

9. 适用场景

满足以下情况时,命令模式很合适:

  • 需要将请求的发起方和执行方解耦
  • 需要支持撤销(undo)和重做(redo)
  • 需要将操作排队执行延迟执行
  • 需要记录操作日志,用于审计或系统恢复
  • 需要将多个操作组合成宏命令批量执行
  • 需要支持事务------一组操作要么全部成功,要么全部回滚

10. Java 中的实际应用

命令模式在 Java 生态中非常常见:

java 复制代码
// Java 中最经典的命令模式:Runnable 接口
// Runnable 就是一个命令对象,Thread 就是 Invoker
Runnable command = () -> System.out.println("执行任务");
Thread invoker = new Thread(command);
invoker.start();

常见的命令模式应用:

  • java.lang.Runnable:最简单的命令接口,Thread 是 Invoker
  • javax.swing.Action:Swing 中按钮点击事件的命令封装
  • java.util.concurrent.Callable:带返回值的命令接口
  • Spring 的 TransactionTemplate:将事务操作封装为命令
  • Netflix Hystrix 的 HystrixCommand:将远程调用封装为命令,支持熔断和降级
  • Android 的 Handler + Runnable:将 UI 操作封装为命令投递到主线程执行

11. 总结

命令模式通过"命令接口 + 具体命令 + 接收者 + 调用者"的结构,将请求封装成独立的对象,彻底解耦了请求的发起方和执行方。命令对象不仅可以被执行,还可以被存储、排队、撤销、组合,让系统在操作管理上获得极大的灵活性。当你需要对"操作"本身做文章(撤销、排队、日志、事务)时,命令模式就是你的首选。

相关推荐
woniu_maggie1 小时前
SAP CPI配置相关
后端
aXin_ya1 小时前
微服务 第二天
java·数据库·微服务
浪客川1 小时前
【百例RUST - 008】枚举
开发语言·后端·rust
李日灐1 小时前
<3>Linux 基础指令:从时间、查找、文本过滤到 .zip/.tgz 压缩解压与常用热键
linux·运维·服务器·开发语言·后端·面试·指令
Bernard02151 小时前
给普通人的 AI 黑话翻译手册:一文看懂 LLM、RAG、Agent 到底是什么
前端·后端
希望永不加班1 小时前
Spring AOP 核心概念:切面、通知、切点、织入
java·数据库·后端·mysql·spring
泰式大师1 小时前
# 为什么我认为 Hermes 需要说明 self-evolution 的设计来源
后端
胖纳特1 小时前
Seafile 文件预览增强方案:集成 BaseMetas Fileview 突破格式限制
前端·后端
fliter1 小时前
Rust这四个问题,从新手到专家都在被折磨
后端