配套代码:GitHub 仓库 · 本篇完整源码 · 行为测试。使用 JDK 25 与 Gradle,包名为 com.hanserwei.patterns.command

系列导航:Java 25 设计模式学习指南

上一篇:责任链 · 下一篇:解释器

编辑器工具栏、快捷键和自动操作都可能发起文本追加。若每个入口都直接修改编辑器,还要各自保存旧内容来支持撤销,交互入口与编辑细节就紧密绑在一起。

命令对象保存本次操作的意图和参数。调用者只知道 execute 和 undo,接收者负责真正持有文本,历史管理器负责执行顺序。

从问题提炼设计意图

命令把请求封装为对象,使请求可以被参数化、排队、记录或撤销。撤销能力需要命令额外保存恢复信息,并非封装后自动获得。

对象职责与协作关系

示例角色 职责
Command 统一执行与撤销契约
AppendCommand 具体命令,保存追加内容及执行前状态
TextEditor 接收者,持有实际文本
CommandHistory 调用者,执行命令并维护后进先出历史
classDiagram
    Command <|.. AppendCommand
    AppendCommand --> TextEditor : changes
    CommandHistory o-- Command : history
    Demo ..> CommandHistory : invokes

代码思路:变化应该落在哪个对象上

AppendCommand 构造时绑定 editor 与 suffix,这时并不修改文本。execute 才读取当前内容,保存到 previous,然后执行追加。旧状态必须在执行时获取,而不是在创建命令时获取,因为创建与执行之间编辑器可能已经变化。

executed 标志拒绝未撤销前重复执行同一个命令对象。undo 恢复 previous 并清除标志,使一次成功执行对应一次撤销。它依赖调用方按正确顺序使用,不能替代完整的编辑器版本控制。

CommandHistory 先执行再入栈,所以失败请求不会污染历史。撤销时先 peek、调用 undo 成功后再 pop,避免撤销失败时丢掉需要处理的历史条目。两次追加后逐次撤销,可以观察状态回到中间值再回到初始值。

关键实现与独立运行

配套仓库中的包名是 com.hanserwei.patterns.command,源码目录为 src/main/java/com/hanserwei/patterns/command/。仓库地址统一见系列导航。以下展示关键文件的完整内容;其余角色和测试在同一仓库中,每个顶级类型各占一个文件。

AppendCommand.java

package com.hanserwei.patterns.command;

import java.util.Objects;

/** 保存追加意图与执行前状态的具体命令. */
public final class AppendCommand implements Command {
  /** 真正持有文本的接收者. */
  private final TextEditor editor;

  /** 要追加的非空文本. */
  private final String suffix;

  /** 执行前文本,供撤销恢复. */
  private String previous;

  /** 标记命令当前是否处于已执行状态. */
  private boolean executed;

  /** 绑定接收者和本次请求参数. */
  public AppendCommand(TextEditor editor, String suffix) {
    this.editor = Objects.requireNonNull(editor, "editor");
    this.suffix = Objects.requireNonNull(suffix, "suffix");
  }

  /** 保存旧文本后执行追加;拒绝重复执行. */
  @Override
  public void execute() {
    if (executed) {
      throw new IllegalStateException("Command already executed");
    }
    previous = editor.text();
    editor.replace(previous + suffix);
    executed = true;
  }

  /** 恢复旧文本;要求调用者按历史逆序撤销. */
  @Override
  public void undo() {
    if (!executed) {
      throw new IllegalStateException("Command not executed");
    }
    editor.replace(previous);
    executed = false;
  }
}

CommandHistory.java

package com.hanserwei.patterns.command;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Objects;

/** 执行命令并按后进先出顺序管理撤销. */
public final class CommandHistory {
  /** 仅保存成功执行且尚未撤销的命令. */
  private final Deque<Command> commands = new ArrayDeque<>();

  /** 先执行,再登记;失败命令不会进入历史. */
  public void execute(Command command) {
    Objects.requireNonNull(command, "command").execute();
    commands.push(command);
  }

  /** 撤销最近一条命令;历史为空时返回 false. */
  public boolean undo() {
    if (commands.isEmpty()) {
      return false;
    }
    commands.peek().undo();
    commands.pop();
    return true;
  }
}

Demo.java 展示调用方如何装配这些对象:

package com.hanserwei.patterns.command;

/** 演示本章对象的装配方式和可观察结果. */
public final class Demo {
  /** 禁止实例化演示入口. */
  private Demo() {}

  /** 运行独立示例;args 为未使用的命令行参数. */
  public static void main(String[] args) {
    TextEditor editor = new TextEditor();
    CommandHistory history = new CommandHistory();
    history.execute(new AppendCommand(editor, "Java"));
    history.execute(new AppendCommand(editor, " 25"));
    history.undo();
    System.out.println(editor.text());
  }
}

在配套代码仓库根目录运行;Windows 使用 gradlew.bat 替换 ./gradlew

./gradlew runCommand
./gradlew test --tests 'com.hanserwei.patterns.command.PatternTest'

示例的业务输出如下,省略 Gradle 自身的任务提示:

Java

用测试确认模式的行为

两次追加按逆序撤销;空历史返回 false;重复执行已执行命令失败,且失败不会新增历史记录。

对应测试位于 src/test/java/com/hanserwei/patterns/command/PatternTest.java。建议先运行现有测试,再改动一个协作环节,观察哪个断言能够发现问题。

常见用法

  • 编辑器、绘图软件的菜单动作与撤销历史。
  • 任务队列需要保存操作参数,并在稍后执行。
  • 宏命令把多次用户动作组织成一个可调用对象。

适用边界与容易踩的坑

不是所有动作都能撤销。邮件已发送、外部扣款已完成时,恢复一个内存字段不会撤销现实中的副作用;应该设计补偿动作或明确不可撤销。

本例要求一个历史管理器顺序操作编辑器。绕过历史直接改文本、跨历史管理器乱序撤销或并发编辑,都可能让 previous 覆盖较新的合法结果。若需要协作编辑,应增加版本检查或采用更适合的操作模型。

队列命令持有 Java 对象引用,并不自动具备持久化能力。落库时应设计稳定的命令类型、参数和版本,并考虑幂等性;不能直接假定把命令对象序列化就解决了重启恢复和重复执行。

与相近模式比较

策略封装可替换算法,通常回答“如何计算”;命令封装一次请求,关注“执行哪次操作、由谁执行、何时执行”。备忘录只保存状态,可以被命令拿来实现撤销。

动手练习

增加 redo 栈:成功 undo 后保存可重做命令,执行新命令时清空 redo。先为“撤销后新输入不能重做旧分支”写测试,再讨论命令执行到一半抛异常时,当前先执行后入栈策略的能力边界。

系列导航:Java 25 设计模式学习指南

上一篇:责任链 · 下一篇:解释器