设计模式学习笔记(十七)命令模式
概念
命令模式(Command Pattern)是一种行为型设计模式,它将请求封装成对象,以便更好地参数化客户端操作,并支持队列、日志记录、撤销等额外的操作。通过将请求发送者和请求接收者解耦,命令模式可以提供更大的灵活性和可扩展性。
在现在的生活中,越来越多的家庭开始使用智能设备通过语音就能实现灯、电视等电子设备的开关,我们现用程序模拟我们的语音,比如,开灯,关灯、开电视、关电视,都用程序的某个方法来控制开关。
//口令类,请求的发送者
public class Command {
private LightHandler light; //电灯实例,请求的接收
public void Release() { //通过下达的指令执行电灯开或者关
light = new LightHandler();
light.execute();
}
}
通过上述例子我们能明显的看出,命令发送者与接收者存在明显的耦合或者说是依赖关系,通过发送者调用接收者的方法实现的。这样的缺点就是如果我们想要更换请求接收者(LightHandler)则需要修改源代码,违背了“开闭原则”。如果不修改源代码,而是直接增加个新的类,则会导致代码冗余过多。
如果用户灵机一闪,本来开灯命令控制的是客厅的灯,那我们想改成控制卧室的等,那么客厅灯LightHandler就得改成其他接收类。
那我们梳理一下设计思路,首先要做的就是发送者与接收者要解耦合,通过某种指令去控制某个电器。
比如 家里有两台电视,两个灯,那我们的开灯命令用户既可以设置为开客厅的灯,也可以设置为开卧室的灯。因此我们需要用一个中间控制器来控制发送者与接收者消息传递与执行。这个控制器就叫做命令类
Command(抽象命令类):抽象命令类一般是一个抽象类或接口,在其中声明了用于执行请求的execute()等方法,通过这些方法可以调用请求接收者的相关操作。
ConcreteCommand(具体命令类):具体命令类是抽象命令类的子类,实现了在抽象命令类中声明的方法,它对应具体的接收者对象,将接收者对象的动作绑定其中。在实现execute()方法时,将调用接收者对象的相关操作(Action)。
Invoker(调用者):调用者即请求发送者,它通过命令对象来执行请求。一个调用者并不需要在设计时确定其接收者,因此它只与抽象命令类之间存在关联关系。在程序运行时可以将一个具体命令对象注入其中,再调用具体命令对象的execute()方法,从而实现间接调用请求接收者的相关操作。
Receiver(接收者):接收者执行与请求相关的操作,它具体实现对请求的业务处理。
命令模式的本质是对请求进行封装,一个请求对应于一个命令,将发出命令的责任和执行命令的责任分割开。每一个命令都是一个操作:请求的一方发出请求要求执行一个操作;接收的一方收到请求,并执行相应的操作。命令模式允许请求的一方和接收的一方独立开来,使得请求的一方不必知道接收请求的一方的接口,更不必知道请求如何被接收、操作是否被执行、何时被执行,以及是怎么被执行的。
命令模式的关键在于引入了抽象命令类,请求发送者针对抽象命令类编程,只有实现了抽象命令类的具体命令才与请求接收者相关联。在最简单的抽象命令类中只包含了一个抽象的execute()方法,每个具体命令类将一个Receiver类型的对象作为一个实例变量进行存储,从而具体指定一个请求的接收者,不同的具体命令类提供了execute()方法的不同实现,并调用不同接收者的请求处理方法。
示例
示例一:使用命令模式实现开关的控制
1、需要创建电灯和电视两个实体类。
2、需要一个系统来记录当前系统中有哪些语音命令集合。
3、命令发送者:每一个实体都是一个语音命令与具体执行的对应关系,因此创建此类后需要set命令和执行。
4、抽象命令类:定义命令方法。
5、具体命令类:集成抽象命令类,并具体的根据指令去调用对应的设备,具体命令类与设备实体是一对一的关系。
当我们需要添加设备时,只需要创建对应的设备类及具体命令类即可。
代码实现
//语音系统控制程序,用来自定义语音指向那个电器
class VoiceSystem {
List<VoiceSend> voiceSends = new ArrayList<>(); //语音指令集合
public void addVoice(VoiceSend voiceSend) {
voiceSends.add(voiceSend);
}
public void removeVoice(VoiceSend voiceSend) {
voiceSends.remove(voiceSend);
}
//显示语音及对应的操作
public void display() {
for (Object obj : voiceSends) {
System.out.println(((VoiceSend)obj).getVoice());
}
System.out.println("------------------------------");
}
}
//语音类:请求发送者
class VoiceSend {
private String voice; //语音指令
private Command command; //维持一个抽象命令对象的引用
public String getVoice() {
return voice;
}
//注入语音
public VoiceSend(String voice) {
this.voice = voice;
}
//注入语音对应的命令
public void setCommand(Command command) {
this.command = command;
}
//发送请求的方法
public void onSend() {
System.out.print("发送语音:");
command.execute(voice);
}
}
//抽象命令类
abstract class Command {
public abstract void execute(String cmd);
}
//开关灯的具体命令类
class LightHandler extends Command{
private Light light; //灯泡的开或关
public LightHandler() {
light = new Light();
}
@Override
public void execute(String cmd) {
light.display(cmd);
}
}
//开关电视的具体命令类
class TvHandler extends Command{
private TV tv; //灯泡的开或关
public TvHandler() {
tv = new TV();
}
@Override
public void execute(String cmd) {
tv.display(cmd);
}
}
//灯类
class Light {
public void display(String cmd) {
System.out.println(cmd);
}
}
//电视类
class TV {
public void display(String cmd) {
System.out.println(cmd);
}
}
客户端
//定义语音
VoiceSend voiceSend1,voiceSend2,voiceSend3,voiceSend4;
voiceSend1 = new VoiceSend("开灯");
voiceSend2 = new VoiceSend("关灯");
voiceSend3 = new VoiceSend("开电视");
voiceSend4 = new VoiceSend("关电视");
//定义语音对应的设备:对应的消息接收者
Command cmd1,cmd2,cmd3,cmd4;
cmd1 = cmd2 = new LightHandler();
cmd3 = cmd4 = new TvHandler();
voiceSend1.setCommand(cmd1);
voiceSend2.setCommand(cmd2);
voiceSend3.setCommand(cmd3);
voiceSend4.setCommand(cmd4);
//将语音放入系统中
VoiceSystem voiceSystem = new VoiceSystem();
voiceSystem.addVoice(voiceSend1);
voiceSystem.addVoice(voiceSend2);
voiceSystem.addVoice(voiceSend3);
voiceSystem.addVoice(voiceSend4);
//展示设置了那些语音
voiceSystem.display();
voiceSend1.onSend();
voiceSend2.onSend();
voiceSend3.onSend();
voiceSend4.onSend();
执行结果
开灯
关灯
开电视
关电视
------------------------------
发送语音:开灯
发送语音:关灯
发送语音:开电视
发送语音:关电视
示例二:命令队列
有些时候,一条命令不只是对应一个响应者的执行,比如我我们想定义一条命令“打开电器”,希望电灯和电视都打开,那么我们就用到了命令队列,命令队列的实现方式有很多,按照我们的例子也就是最常用的一种实现方式就是直接增加一个新的命令队列类CommandQueue,并通过调用可以依次执行任务。
首先定义命令队列类:封装了命令抽象类的集合
public class CommandQueue {
private List<Command> commandList;
//懒加载模式
public CommandQueue() {
commandList = new ArrayList<>();
}
public void addCommand(Command command) {
commandList.add(command);
}
public void removeCommand(Command command) {
commandList.remove(command);
}
public void execute(String cmd) {
for (Command command : commandList) {
command.execute(cmd);
}
}
}
发送者类改造
//消息发送者:一条命令执行多个任务
class VoiceSends {
private String voice; //语音指令
private CommandQueue commands; //命令队列
//注入命令
public VoiceSends(CommandQueue commands) {
this.commands = commands;
}
public void setVoice(String voice) {
this.voice = voice;
}
//发送请求的方法
public void onSend() {
System.out.print("执行任务队列:");
commands.execute(voice);
}
}
示例三:撤销功能的实现
在我们使用微信、qq等聊天工具的时候,有一个“后悔药”功能可以将发送的消息撤回。那么我们使用命令模式来简单的模拟一下消息的撤回功能。这里我们将要用到队列来记录消息。
代码实现
//消息类:请求接收者
public class Messager {
private Stack<String> messages = new Stack<>();
public void sendMessage(String msg) {
messages.add(msg);
System.out.println("发送的消息:" + msg);
}
public void undoMessage() {
if (messages.size() > 0) {
System.out.println("撤回的消息:" + messages.pop());
}else {
System.out.println("没有历史消息...");
}
}
}
//抽象命令类
abstract class Command {
public abstract void execute(String msg); //执行发送命令
public abstract void undo(); //执行撤回命令
}
//具体命令类
class MessageCommand extends Command{
private Messager messager = new Messager();
//调用消息接收者的 发送消息方法
@Override
public void execute(String msg) {
messager.sendMessage(msg);
}
//调用消息接收者的 撤回消息方法
@Override
public void undo() {
messager.undoMessage();
}
}
//聊天窗口类:消息发送者
class ChatWindow {
private Command command;
//构造注入
public ChatWindow(Command command) {
this.command = command;
}
//发送消息按钮
public void onSend(String msg) {
command.execute(msg);
}
//撤回上一条消息按钮
public void onUndo() {
command.undo();
}
}
客户端
//先创建命令及对应的具体操作
Command command = new MessageCommand();
//创建发送者对象,并通过构造方法注入命令类,通过命令类 将发送者连接到对应的接收者
ChatWindow chatWindow = new ChatWindow(command);
//测试发送消息
chatWindow.onSend("hello");
chatWindow.onSend("您好");
//测试撤回消息
chatWindow.onUndo();
chatWindow.onUndo();
chatWindow.onUndo();
执行结果
发送的消息:hello
发送的消息:您好
撤回的消息:您好
撤回的消息:hello
没有历史消息...
示例四:从日志中执行命令
有时候我们在部署服务器集群的时候,以前需要每台服务器都要手动部署一套数据库、缓存、工程等服务,并且每台服务器的环境不一定相同非常的麻烦,后来就推出了docker,减少了服务器之间差异而造成的部署困难,但这样的话还是需要进行手动下载安装,后来我们使用了docker的容器编排,而本例从日志中执行命令也是同样的道理。有时候我们想要执行一些特定流程的操作,但是又怕执行到中途忽然断电或宕机,那么就用到了日志。
具体方法:
1、我们可以增加一个文件操作类,文件操作类的作用是将命令队列和log日志的序列化和反序列化(Serializable)。
2、通过发送者创建命令队列(CommendList),并提供保存方法,该保存方法调用文件util进行序列化成log文件。
3、发送者实现恢复方法,该方法通过调用文件util将文件反序列化为命令队列,并循环执行命令。
4、其他类的实现参考普通命令模式。
示例五:宏命令
宏命令是命令模式的一种特殊形式,它将多个子命令组合成一个更大的命令(MacroCommand)。宏命令的目的是将一系列操作封装成一个单一的命令,使得调用者可以一次性执行或撤销这个命令,而不需要了解具体的子命令。
在命令模式中,通常有一个命令接口(Command),它定义了命令的执行方法,然后有多个具体的命令类实现这个接口,并分别执行各自的功能。
而宏命令则是在命令模式的基础上引入的一个新的类,这个类与具体命令类平级,它可以包含多个具体的命令对象,并执行它们。
理解宏命令可以通过以下步骤:
1、创建具体的命令类:首先,你需要创建多个具体的命令类,每个类都实现了命令接口,并且负责执行一个特定的功能。
2、创建宏命令类:创建一个宏命令类,它实现了命令接口,并且内部维护了一个命令对象列表。它提供了添加、移除和执行子命令的方法。
3、组合子命令:在宏命令中,你可以通过添加和移除子命令的方式来组合多个具体的命令对象。可以按照需要随时添加或移除子命令。
4、执行宏命令:当调用者需要执行一系列操作时,可以调用宏命令的执行方法。宏命令会按照添加子命令的顺序依次执行每个子命令的功能。
宏命令实际上就是对多个具体命令类的封装,通过调用这个宏命令,可以省去单个调用的麻烦。在用处上有点类似于示例四中的使用日志调用,目的都是为了实现对执行命令的编排,区别就是日志可以保存到硬盘上,而宏命令不需要保存。
通过使用宏命令,可以将多个具体的命令对象组合起来,形成一个更大的命令对象。这样,调用者无需关心具体的子命令是如何执行的,只需要调用宏命令的执行方法即可。这样可以简化客户端代码,提高代码的可维护性和可扩展性。
总结
优点:
1、解耦调用者和接收者:命令模式将请求发送者与接收者解耦,使得调用者不需要知道接收者的任何细节,只需通过命令进行请求。
2、容易扩展新的命令:因为命令模式将命令封装成独立的对象,所以很容易添加新的命令类,无需修改现有的代码。
3、支持撤销和重做:命令模式可以记录操作历史,从而支持撤销和重做功能。
4、支持事务操作:可以使用命令模式来实现事务操作,将一组相关的命令封装在一个命令对象中,确保它们能够一起执行或撤销。例如日志调用、宏命令等
缺点:
1、命令类的增多:如果系统中有大量的命令类,可能会导致类的数量增加,影响代码的可读性和维护性。
2、运行效率:每个命令都需要封装成一个对象,这可能会导致一定的运行效率损失。
适用场景:
1、需要将请求发送者和接收者解耦的场景。
2、需要支持撤销、重做或事务操作的场景。
3、需要记录日志、队列请求或延迟执行的场景。
4、需要实现命令的参数化和回调操作的场景。
例如,在图形编辑器中,可以使用命令模式实现各种绘图操作,如绘制线条、绘制圆形等。每个绘图操作都可以封装成一个具体的命令对象,然后由调用者发送命令来执行相应的操作。这样可以方便地扩展新的绘图操作,实现撤销、重做功能,并记录绘图的历史记录。