16|命令:把一次操作变成可保存的对象
配套代码: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 设计模式学习指南