【设计模式】行为型模式其二: 命令模式

56 阅读10分钟

命令模式

命令模式概述

image.png

现实生活

  • 相同的开关可以通过不同的电线来控制不同的电器
  • 开关 == 请求发送者
  • 电灯 == 请求的最终接收者和处理者
  • 开关和电灯之间并不存在直接耦合关系,它们通过电线连接在一起,使用不同的电线可以连接不同的请求接收者

软件开发

  • 按钮 == 请求发送者
  • 事件处理类 == 请求的最终接收者和处理者
  • 发送者与接收者之间引入了新的命令对象(类似电线),将发送者的请求封装在命令对象中,再通过命令对象来调用接收者的方法
  • 相同的按钮可以对应不同的事件处理类

动机

  • 请求发送者和接收者完全解耦
  • 发送者与接收者之间没有直接引用关系
  • 发送请求的对象只需要知道如何发送请求,而不必知道如何完成请求

命令模式的定义

将一个请求封装为一个对象,从而让你可以用不同的请求对客户进行参数化,对请求排队或者记录请求日志,以及支持可撤销的操作。

  • 别名为动作(Action)模式或事务(Transaction)模式
  • “用不同的请求对客户进行参数化”
  • “对请求排队”
  • “记录请求日志”
  • “支持可撤销操作”

命令模式结构

image.png

命令模式包含以下4个角色:

  • Command(抽象命令类)
  • ConcreteCommand(具体命令类)
  • Invoker(调用者)
  • Receiver(接收者)

命令模式的实现

  • 命令模式的本质是对请求进行封装, 外观模式是对操作进行封装
  • 一个请求对应于一个命令,将发出命令的责任和执行命令的责任分开
  • 命令模式允许请求的一方和接收的一方独立开来,使得请求的一方不必知道接收请求的一方的接口,更不必知道请求如何被接收、操作是否被执行、何时被执行,以及是怎么被执行的

光看概念还是云里雾里的,现在我们来通过实例进行学习

实例学习

需求:

为了用户使用方便,某系统提供了一系列功能键,

用户可以自定义功能键的功能,例如功能键FunctionButton可以用于退出系统(由SystemExitClass类来实现),

也可以用于显示帮助文档(由DisplayHelpClass类来实现)。

用户可以通过修改配置文件来改变功能键的用途。

现使用命令模式来设计该系统,使得功能键类与功能类之间解耦,可为同一个功能键设置不同的功能

具体调用类

// 这里的类是对具体业务的执行,需要对完成真实操作,比如真正的实现退出系统

public class SystemExitClass {
   public void exit() {
      System.out.println("退出系统!");
   }
}
public class DisplayHelpClass {
   public void display() {
      System.out.println("显示帮助文档!");
   }
}

抽象命令类

public abstract class Command {
   public abstract void execute();
}

具体命令类

// 具体完成命令的类,里面需要有对真实请求接收者的引用

public class HelpCommand extends Command{
   private DisplayHelpClass hcObj;   //维持对请求接收者的引用
   
   public HelpCommand() {
      hcObj = new DisplayHelpClass();
   }

   //命令执行方法,将调用请求接收者的业务方法
   public void execute() {
      hcObj.display();
   }
}
public class ExitCommand extends Command{
   private SystemExitClass seObj;  //维持对请求接收者的引用
   
   public ExitCommand() {
      seObj = new SystemExitClass();
   }
   
   //命令执行方法,将调用请求接收者的业务方法
   public void execute() {
      seObj.exit();
   }
}

请求调用者

// 这个类是调用命令的类,里面需要维持一个抽象命令对象,

// 比如说: 我在自定义按键时,我需要为它输入一个真实的想替换的按键,这里我们使用XML文件来注入这个功能。

public class FunctionButton {
   private Command command;  //维持一个抽象命令对象的引用
   
   //为功能键注入命令
   public void setCommand(Command command) {
      this.command = command;
   }
   
   //发送请求的方法
   public void click() {
      System.out.print("单击功能键: ");
      command.execute();
   }
}

XML文件

<?xml version="1.0"?>
<config>
   <className>designpatterns.command.HelpCommand</className>
</config>

提示:这里的路径写你命令类的完整类名,不要无脑复制

XML文件读取类

public class XMLUtil {
   //该方法用于从XML配置文件中提取具体类类名,并返回一个实例对象
   public static Object getBean() {
      try {
         //创建DOM文档对象
         DocumentBuilderFactory dFactory = DocumentBuilderFactory.newInstance();
         DocumentBuilder builder = dFactory.newDocumentBuilder();
         Document doc;
         doc =builder.parse(new File("./config.xml"));

         //获取包含类名的文本节点
         NodeList nl = doc.getElementsByTagName("className");
         Node classNode=nl.item(0).getFirstChild();
         String cName=classNode.getNodeValue();

         //通过类名生成实例对象并将其返回
         Class c=Class.forName(cName);
         Object obj=c.getConstructor().newInstance();
         return obj;
      }
      catch(Exception e) {
         e.printStackTrace();
         return null;
      }
   }
}

客户端调用

public class Client {
   public static void main(String args[]) {
      FunctionButton fb = new FunctionButton();
      Command command; //定义命令对象
      command = (Command)XMLUtil.getBean(); //读取配置文件,反射生成对象
      
      fb.setCommand(command); //将命令对象注入功能键
      fb.click(); //调用功能键的业务方法
   }
}

输出及分析

单击功能键: 显示帮助文档!

分析: 我通过XML文件获得当前我需要进行的操作,这里是显示帮助文档的命令,将命令封装好将其给与调用请求者,当需要进行功能调用时它会触发命令,完成真实操作。

调用者持有一个 Command 的引用,它不需要知道具体的命令类是什么,做到了调用与实现分离。

分析:

  • 如果需要更换具体命令类,无须修改源代码,只需修改配置文件,完全符合开闭原则
  • 每一个具体命令类对应一个请求的处理者(接收者),通过向请求发送者注入不同的具体命令对象可以使相同的发送者对应不同的接收者,从而实现“将一个请求封装为一个对象,用不同的请求对客户进行参数化”,客户端只需要将具体命令对象作为参数注入请求发送者,无须直接操作请求的接收者

类的分布

(1) FunctionButton:功能键类,充当请求调用者(请求发送者)
(2) Command:抽象命令类
(3) ExitCommand:退出命令类,充当具体命令类
(4) HelpCommand:帮助命令类,充当具体命令类
(5) SystemExitClass:退出系统模拟实现类,充当请求接收者
(6) DisplayHelpClass:显示帮助文档模拟实现类,充当请求接收者
(7) Client:客户端测试类


上面我们完成了基本的命令模式编写,现在我们对命令模式进行扩展。

模式编写进阶

命令队列

  • 出现问题:当一个请求发送者发送一个请求时,有不止一个请求接收者产生响应,这些请求接收者将逐个执行业务方法,完成对请求的处理
  • 解决办法:增加一个CommandQueue类,由该类负责存储多个命令对象,而不同的命令对象可以对应不同的请求接收者

如果拿上面的代码来做修改,该怎么办?

  1. 写一个命令队列类,里面维护一个列表或队列,并写上基本的注入命令方法,删除命令方法,然后定义执行方法。

执行方法需要去唤醒所有的真实操作对象去执行方法

类似这样:

public class CommandQueue {
    private List<Command> commandList = new ArrayList<>();

    public void addCommand(Command command) {
        commandList.add(command);
    }
    public void removeCommand(Command command){
        commandList.remove(command);
    }
    public void execute(){
        for (Command command : this.commandList) {
            command.execute();
        }
    }
}

2. 我们需要将该命令队列类在客户端进行装配,该操作由客户端修改配置来进行。

举个例子: 我们在设置鼠标宏的时候,是点击某一个按键上执行很多操作,这是需要操作者手动装配的。

所以呢,调用者对象里面需要有对消息队列类的注入,调用者还是正常调用按钮,然后执行一堆功能。

这时候可能有人会觉得不如一个命令一个命令的执行?

错,我们一次一次执行命令时,这个命令的顺序可能会遗忘,而我们选择命令队列呢,可以一次装配,多次使用。

日志保存

我们可以为命令模式添加日志记录:

  • 将请求的历史记录保存下来,通常以日志文件(Log File)的形式永久存储在计算机中
  • 为系统提供一种恢复机制
  • 可以用于实现批处理
  • 防止因为断电或者系统重启等原因造成请求丢失,而且可以避免重新发送全部请求时造成某些命令的重复执行

命令模式引入日志记录需要将命令对象序列化写到日志文件中,主要是为了能够在日志中记录完整的命令相关信息。

比如,一个命令对象可能包含:

  • 命令类型
  • 对应的接收者
  • 执行命令时需要的各种参数

将这些信息记录到日志中有几个好处:

  1. 当命令执行失败时,能从日志中快速查看执行该命令时传入的参数和配置信息
  2. 能够重放该命令,重新执行
  3. 分析历史命令信息,了解系统的执行流程频率

那我该怎么实现呢?

  1. 让命令类去实现Serilaziable接口,这样可以让JVM读取到并为其分配一个序列化ID。

  2. 使用时,可以使用ObjectOutputStream 结合 FileOutputStream 来将对象信息 以二进制形式 写入 日志文件 ,便于之后对象的取出操作。

  3. 对日志文件进行操作时,可以使用 FileInputStreamObjectInputStream来读取, 这样可以读取写入类中的属性及调用方法。

  4. 如果还想查看调用方法传入的参数,那只能通过以下几种办法

    1. 在构造函数中保存方法的所有参数
    class ExecuteCommand extends Command{
    
      String param1;
      String param2;  
    
      ExecuteCommand(String param1, String param2){
         this.param1 = param1;
         this.param2 = param2;  
      }
    
      void method1(){
         // use param1 and param2  
      }
    }
    

    序列化/反序列化时会保存这些参数。

    1. 实现Externalizable,在readExternal()/writeExternal()中读取/写入参数
    class ExecuteCommand implements Externalizable{
    
      String param1;   
      String param2;  
    
      public void writeExternal(ObjectOutput out){
        out.writeUTF(param1);
        out.writeUTF(param2);  
      }
    
      public void readExternal(ObjectInput in){
        param1 = in.readUTF();
        param2 = in.readUTF();    
      }
    
      void method1(){
        // use param1 and param2
      }  
    }  
    

    3. 在方法的调用时点序列化Command对象

    class ExecuteCommand extends Command{
    
      void method1(String param1, String param2){
        // 这里需要属性,将方法参数保存在对象中
        // 序列化,记录调用此刻的状态  
        objectOutputStream.writeObject(this);  
        // 执行方法
       }  
    }
    

    注意: 在方法里写入对象时,需要将该二进制流写入文件,读取时读取同一个流。

撤销操作

当我们完成一个操作后,我们发现出错了,然后我们需要进行撤销操作,该怎么办?

步骤:

  1. 要实现撤销操作,那么控制界面肯定必须要有撤销按钮,因此在调用类设定一个undo()方法,然后客户端可以调用。
  2. 抽象命令类肯定需要有撤销和操作的方法,然后让具体命令去继承
  3. 具体命令里既然要有撤销操作,那肯定必须要保存上一步的值,作为undo的信息

代码实例类图:

image.png

可以实现简易的加法计算器和撤销操作。

加法类:请求接收者

// 这里为执行者,里面维护了一个变量num。

public class Adder {
   private int num=0; //定义初始值为0
   
  //加法操作,每次将传入的值与num作加法运算,再将结果返回
   public int add(int value) {
      num += value;
      return num;
   }
}

抽象命令类

// 定义执行操作和撤销操作的命令

public abstract class AbstractCommand {
   public abstract int execute(int value); //声明命令执行方法execute()
   public abstract int undo(); //声明撤销方法undo()
}

具体命令类

// 完成基本的加法功能和单步撤销功能。 如果执行者执行操作获得的结果是不影响执行者的属性,可以不用重新执行加负数操作(而是直接返回value)。但是执行者拥有一个自己的属性(表示计算结果是连贯的,所以不能通过简单撤销来获得上一步的值, 而是需要执行一次逆操作)

public class AddCommand extends AbstractCommand {
   private Adder adder = new Adder();
   private int value;

   //实现抽象命令类中声明的execute()方法,调用加法类的加法操作
   public int execute(int value) {
      this.value=value;
      return adder.add(value);
   }

    //实现抽象命令类中声明的undo()方法,通过加一个相反数来实现加法的逆向操作
   public int undo() {
      return adder.add(-value);
   }
}

计算器界面类:请求发送者

// 这里和一般命令模式一样,注入命令对象

// 记得定义执行运算的操作和执行撤销的操作

public class CalculatorForm {
   private AbstractCommand command;
   
   public void setCommand(AbstractCommand command) {
      this.command = command;
   }
   
   //调用命令对象的execute()方法执行运算
   public void compute(int value) {
      int i = command.execute(value);
      System.out.println("执行运算,运算结果为:" + i);
   }
   
  //调用命令对象的undo()方法执行撤销
   public void undo() {
      int i = command.undo();
      System.out.println("执行撤销,运算结果为:" + i);
   }
}

客户端调用类

// 注入命令对象,执行需要的操作

public class Client {
   public static void main(String args[]) {
      CalculatorForm form = new CalculatorForm();
      AbstractCommand command;
      command = new AddCommand();

      form.setCommand(command); //向发送者注入命令对象

      form.compute(10);
      form.compute(5);
      form.compute(10);
      form.undo();   //撤销
   }
}

输出分析

执行运算,运算结果为:10
执行运算,运算结果为:15
执行运算,运算结果为:25
执行撤销,运算结果为:15

分析:

运算正常进行,撤销操作其实是进行了一次add(-value)

模式优缺点

优点

  • 降低系统的耦合度
  • 新的命令可以很容易地加入到系统中,符合开闭原则
  • 可以比较容易地设计一个命令队列或宏命令(组合命令)
  • 为请求的撤销(Undo)和恢复(Redo)操作提供了一种设计和实现方案*

缺点

使用命令模式可能会导致某些系统有过多的具体命令类(针对每一个对请求接收者的调用操作都需要设计一个具体命令类)

适用环境

  • 系统需要将请求调用者和请求接收者解耦,使得调用者和接收者不直接交互
  • 系统需要在不同的时间指定请求、将请求排队和执行请求
  • 系统需要支持命令的撤销(Undo)操作和恢复(Redo)操作
  • 系统需要将一组操作组合在一起形成宏命令