设计模式--命令模式

目录

本文的结构如下:

  • 什么是命令模式
  • 为什么要用该模式
  • 模式的结构
  • 代码示例
  • 优点和缺点
  • 适用环境
  • 模式应用
  • 总结

一、前言

在软件设计中,经常需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是哪个,我们只需在程序运行时指定具体的请求接收者即可,此时,可以使用命令模式来进行设计,使得请求发送者与请求接收者消除彼此之间的耦合,让对象之间的调用关系更加灵活。

二、什么是命令模式

上面说了,命令模式可以将请求发送者和接收者完全解耦,发送者与接收者之间没有直接引用关系,发送请求的对象只需要知道如何发送请求,而不必知道如何完成请求。那么到底什么是命令模式?

2.1、官方解释

命令模式(Command Pattern):将一个请求封装为一个对象,从而让我们可用不同的请求对客户进行参数化;对请求排队或者记录请求日志,以及支持可撤销的操作。命令模式是一种对象行为型模式,其别名为动作(Action)模式或事务(Transaction)模式。

2.2、举个例子

那怎么理解上面比较正式的定义呢?

这里借用刘伟老师的一张图。

20171103_command01.png

假设你新买的200多平米的房子已经到手,正准备装修,你买了一些开关,用于控制新买的电器,比如电灯和排气扇。开关刚买回来的时候,是不知道具体控制哪个电器的,只有当你用电线将开关和电器连接起来后,一个开关才控制了一个具体的电器。和电灯相连就控制了电灯的开关,和排气扇相连则控制了排气扇的开关。

开关的这种设计思路其实就是一个很好的命令模式。开关可以理解为请求的发送者,电灯和排气扇则为请求的接受者,不同的请求就可以理解为是连接开关和电器的不同的电线。通过这种模式,开关(发送者)就和电器(接收者)松耦合了,只需要更换一下连接的电线(不同的请求),就能够轻松实现同一个开关(发送者)控制不同的电器(接收者),也就是用不同的请求对客户进行参数化。

至于“对请求排队或者记录请求日志,以及支持可撤销的操作”又当作何理解呢?请求排队其实就是将很多不同请求放入一个工作队列中,然后接收者将请求从队列中一个一个取出去处理;记录请求日志,就是将请求记录在日志当中,当系统死机后,可以从日志中取出这些请求,再一个个去处理恢复之前的状态。

三、为什么要用该模式

使用命令模式最重要的原因就是为了解耦,通过引入一个第三方--抽象命令,让请求者和接收者松耦合,让对象之间的调用关系更加灵活,这对系统的扩展和维护是有极大好处的。

比如你的豪华大房子又新买了一个电器,恩,就是那种老式吊扇,你想用连接排气扇的开关去控制这个吊扇,怎么办呢?换根电线将开关和吊扇连起来就好了。

四、模式的结构

命令模式的核心在于引入了命令类,通过命令类来降低发送者和接收者的耦合度,请求发送者只需指定一个命令对象,再通过命令对象来调用请求接收者的处理方法,其结构如图所示:

20171103_command02.png

在命令模式结构图中包含如下几个角色:

  • Command(抽象命令类):抽象命令类一般是一个抽象类或接口,在其中声明了用于执行请求的execute()等方法,通过这些方法可以调用请求接收者的相关操作
  • ConcreteCommand(具体命令类):具体命令类是抽象命令类的子类,实现了在抽象命令类中声明的方法,它对应具体的接收者对象,将接收者对象的动作绑定其中。在实现execute()方法时,将调用接收者对象的相关操作(Action)。
  • Invoker(调用者):调用者即请求发送者,它通过命令对象来执行请求。一个调用者并不需要在设计时确定其接收者,因此它只与抽象命令类之间存在关联关系。在程序运行时可以将一个具体命令对象注入其中,再调用具体命令对象的execute()方法,从而实现间接调用请求接收者的相关操作。
  • Receiver(接收者):接收者执行与请求相关的操作,它具体实现对请求的业务处理

命令模式的本质是对请求进行封装,一个请求对应于一个命令,将发出命令的责任和执行命令的责任分割开。每一个命令都是一个操作:请求的一方发出请求要求执行一个操作;接收的一方收到请求,并执行相应的操作。命令模式允许请求的一方和接收的一方独立开来,使得请求的一方不必知道接收请求的一方的接口,更不必知道请求如何被接收、操作是否被执行、何时被执行,以及是怎么被执行的
命令模式的关键在于引入了抽象命令类,请求发送者针对抽象命令类编程,只有实现了抽象命令类的具体命令才与请求接收者相关联。在最简单的抽象命令类中只包含了一个抽象的execute()方法,每个具体命令类将一个Receiver类型的对象作为一个实例变量进行存储,从而具体指定一个请求的接收者,不同的具体命令类提供了execute()方法的不同实现,并调用不同接收者的请求处理方法。

典型的抽象命令类代码:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:25:14
 *
 */
public interface Command {
    void execute();
}

对于请求发送者即调用者而言,将针对抽象命令类进行编程,可以通过构造注入或者设值注入的方式在运行时传入具体命令类对象,并在业务方法中调用命令对象的execute()方法,其典型代码:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:24:43
 *
 */
public class Invoker {
    private Command command;

    public Invoker() {
    }

    public void call() {
        command.execute();
    }

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

具体命令类实现了命令类接口,它与请求接收者相关联,实现了在抽象命令类中声明的execute()方法,并在实现时调用接收者的请求响应方法action(),其典型代码:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:27:56
 *
 */
public class ConcreteCommand implements Command {

    private Receiver receiver;

    public ConcreteCommand(Receiver receiver) {
        this.receiver = receiver;
    }

    @Override
    public void execute() {
        receiver.action();
    }
}

请求接收者Receiver类具体实现对请求的业务处理,它提供了action()方法,用于执行与请求相关的操作,其典型代码:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:28:45
 *
 */
public class Receiver {
    public void action() {
        System.out.println("--------let us go to play computer game--------");
    }
}

五、代码示例

假设我们正在开发一个办公软件,为了给用户更好的体验,打算为这个办公软件加一个人性化的设计,提供一组按钮,每个按钮提供三个功能给用户选择,用户选择其中一个功能与按钮绑定,绑定后用户只要点击按钮就能实现想要的功能。如下:

20171103_command03.png

以Button1为例,可以选择“关闭”,“换肤”,“放大”三个其中的一个。如何设计这个功能呢?

5.1、不好的设计

最开始的代码也许是这样的:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午2:46:15
 *
 */
public class Button1 {
    private SkinPeelerHandler handler;

    public void onClick() {
        handler = new SkinPeelerHandler();
        handler.skinPeeler();// 换肤
    }
}

上面这段代码,Button1是invoker(请求发起者),SkinPeelerHandler是Receiver(请求接收者),它们是直接强耦合在一起的,如果想要将Button1同“放大”绑定起来,似乎只能更改Button1的源码了,这明显破坏了“开闭原则”,对用户来说,完全不具备可操作性,不灵活不实用。

也行有人会说,可以给“关闭”,“换肤”,“放大”功能设计一个公共抽象层,然后Button1可以通过与抽象层来打交道,这样就灵活了。是可以的,但如果是一组毫无关联的接受者呢?它们根本无法抽象出一个共同的抽象层来,这种情况怎么办呢?

5.2、命令模式设计

(1)所以用命令模式吧!(这里就只列换肤和关闭两个功能)

换肤

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午3:56:47
 *
 */
public class SkinPeelerHandler {
    public void skinPeeler() {
        System.out.println("skin peeler");
    }
}

关闭

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午4:02:05
 *
 */
public class CloseHandler {
    public void close() {
        System.out.println("close the software");
    }
}

(2)定义抽象命令:

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:25:14
 *
 */
public interface Command {
    void execute();
}

(3)再定义具体命令:

换肤

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午2:50:45
 *
 */
public class SkinPeelerCommand implements Command {
    private SkinPeelerHandler handler;

    public SkinPeelerCommand(SkinPeelerHandler handler) {
        this.handler = handler;
    }

    @Override
    public void execute() {
        handler.skinPeeler();
    }
}

关闭

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午4:01:24
 *
 */
public class CloseCommand implements Command {

    private CloseHandler handler;

    private CloseCommand(CloseHandler handler) {
        this.handler = handler;
    }

    @Override
    public void execute() {
        handler.close();
    }
}

(4) 调用者

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午2:46:35
 *
 */
public class Button {
    private Command command;

    public Button() {
    }

    public void call() {
        command.execute();
    }

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

(5)最后看客户端

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午4:15:41
 *
 */
public class Client {
    public static void main(String[] args) {
        // 调用者
        Button button = new Button();
        // 这里可以通过配置文件获取具体命令名字,然后通过反射实例化具体命令,这样更换命令时只需修改配置文件而不需要修改源码
        Command command = new SkinPeelerCommand(new SkinPeelerHandler());
        // 将命令传给invoker
        button.setCommand(command);
        button.call();
    }
}

如果需要修改功能键的功能,例如某个功能键可以实现“打开音乐播放器”,只需要对应增加一个新的具体命令类,在该命令类与“打开音乐播放器请求处理者”(MusicHandler)之间创建一个关联关系,然后将该具体命令类的对象通过配置文件注入到某个功能键即可,原有代码无须修改,符合“开闭原则”。在此过程中,每一个具体命令类对应一个请求的处理者(接收者),通过向请求发送者(调用者)注入不同的具体命令对象可以使得相同的发送者对应不同的接收者,从而实现“将一个请求封装为一个对象,用不同的请求对客户进行参数化”,客户端只需要将具体命令对象作为参数注入请求发送者,无须直接操作请求的接收者

5.3、命令模式中的撤销

在命令模式中,可以通过调用一个命令对象的execute()方法来实现对请求的处理,如果需要撤销(undo)请求,可通过在命令类中增加一个逆向操作来实现。

除了通过一个逆向操作来实现撤销(undo)外,还可以通过保存对象的历史状态来实现撤销,后者可使用备忘录模式(Memento Pattern)来实现。

以控制一个游戏角色向前走为例说明。

(1)抽象command

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午1:25:14
 *
 */
public interface Command {
    void execute();

    void undo();
}

(2)接收者

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:14:26
 *
 */
public class Role {
    public void forward() {
        System.out.println("向前走10步!");
    }

    public void back() {
        System.out.print("后退10步!");
    }
}

(3)具体命令

public class ForwardCommand implements Command {

    private Role role;

    public ForwardCommand(Role role) {
        this.role = role;
    }

    @Override
    public void execute() {
        role.forward();
    }

    @Override
    public void undo() {
        role.back();
    }
}

(4)调用者

/**
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午4:55:43
 *
 */
public class OperatorInterface {
    private Command command;

    public void operate() {
        command.execute();
    }

    public void reset() {
        command.undo();
    }

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

(5)客户端

public class Client {
    public static void main(String[] args) {
        OperatorInterface operator = new OperatorInterface();
        Command command = new ForwardCommand(new Role());
        operator.setCommand(command);
        operator.operate();
        operator.reset();
    }
}

结果:
向前走10步!
后退10步!

需要注意的是在本实例中只能实现一步撤销操作,因为没有保存命令对象的历史状态,可以通过引入一个命令集合或其他方式来存储每一次操作时命令的状态,从而实现多次撤销操作。除了Undo操作外,还可以采用类似的方式实现恢复(Redo)操作,即恢复所撤销的操作(或称为二次撤销)。

5.4、请求日志

请求日志就是将请求的历史记录保存下来,通常以日志文件(Log File)的形式永久存储在计算机中。很多系统都提供了日志文件,例如Windows日志文件、Oracle日志文件等,日志文件可以记录用户对系统的一些操作(例如对数据的更改)。请求日志文件可以实现很多功能,常用功能如下:

  1. 一旦系统发生故障,日志文件可以为系统提供一种恢复机制,在请求日志文件中可以记录用户对系统的每一步操作,从而让系统能够顺利恢复到某一个特定的状态;
  2. 请求日志也可以用于实现批处理,在一个请求日志文件中可以存储一系列命令对象,例如一个命令队列;
  3. 可以将命令队列中的所有命令对象都存储在一个日志文件中,每执行一个命令则从日志文件中删除一个对应的命令对象,防止因为断电或者系统重启等原因造成请求丢失,而且可以避免重新发送全部请求时造成某些命令的重复执行,只需读取请求日志文件,再继续执行文件中剩余的命令即可。

这里用一个简单的日志记录说明:

(1)请求接收者

/**
 * 请求接收者。因为Command依赖Operator,它也将随Command对象一起序列化, 所以Operator也实现Serializable接口
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:25:35
 *
 */
class Operator implements Serializable {
    private static final long serialVersionUID = 4962794574238371441L;

    public void insert(String args) {
        System.out.println("insert operation: " + args);
    }

    public void modify(String args) {
        System.out.println("update operation: " + args);
    }

    public void delete(String args) {
        System.out.println("delete peration: " + args);
    }
}

(2)抽象命令类

/**
 * 抽象命令类,由于需要将命令对象写入文件,因此它实现了Serializable接口,保证其序列化
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:24:04
 *
 */
public abstract class Command implements Serializable {
    private static final long serialVersionUID = -4023087706968880848L;
    protected String name; // 命令名称
    protected String args; // 命令参数
    protected Operator operator; // 维持对接收者对象的引用

    public Command(String name) {
        this.name = name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setOperator(Operator operator) {
        this.operator = operator;
    }

    /**
     * 抽象的执行方法execute(),带参数
     * 
     * @param args
     */
    public abstract void execute(String args);

    /**
     * 抽象的执行方法execute(),不带参数
     * 
     * @param args
     */
    public void execute() {
        execute(this.args);
    }
}

(3)具体命令类

/**
 * 具体插入命令类
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:26:28
 *
 */
class InsertCommand extends Command {
    private static final long serialVersionUID = -6239610676788773397L;

    public InsertCommand(String name) {
        super(name);
    }

    public void execute(String args) {
        this.args = args;
        operator.insert(args);
    }
}

/**
 * 具体删除命令类
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:29:19
 *
 */
class DeleteCommand extends Command {
    private static final long serialVersionUID = -4259959904986587353L;

    public DeleteCommand(String name) {
        super(name);
    }

    public void execute(String args) {
        this.args = args;
        operator.delete(args);
    }
}

/**
 * 具体修改命令类
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:28:40
 *
 */
class ModifyCommand extends Command {
    private static final long serialVersionUID = -4259959904986587353L;

    public ModifyCommand(String name) {
        super(name);
    }

    public void execute(String args) {
        this.args = args;
        operator.modify(args);
    }
}

(4)请求发送者

/**
 * 请求发送者
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:31:20
 *
 */
public class OperatorWindow {
    // 定义一个集合来存储每一次操作时的命令对象
    private List<Command> commands = new ArrayList<Command>();
    private Command command;

    // 设置具体命令对象
    public void setCommand(Command command) {
        this.command = command;
    }

    // 执行命令,同时将命令对象添加到命令集合中
    public void call(String args) {
        command.execute(args);
        commands.add(command);
    }

    // 记录请求日志,将命令集合写入日志文件
    public void save() {
        FileUtil.writeCommands(commands);
    }

    // 从日志文件中提取命令集合,并调用所有命令的execute()方法来实现命令的重新执行
    public void recover() {
        List<Command> commands = FileUtil.readCommands();

        for (Command command : commands) {
            command.execute();
        }
    }
}

/**
 * 文件操作类
 * 
 * @author w1992wishes
 * @created @2017年11月3日-下午5:32:16
 *
 */
class FileUtil {
    
    private static final Logger LOGGER = LoggerFactory.getLogger(FileUtil.class);
    
    public static void writeCommands(List<Command> commands) {
        try {
            FileOutputStream fos = new FileOutputStream("operator.log");
            ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(fos));
            oos.writeObject(commands);
            oos.close();
        } catch (Exception e) {
            LOGGER.error("writeCommands error!", e);
        }
    }

    public static List<Command> readCommands() {
        try {
            FileInputStream fis = new FileInputStream("operator.log");
            ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(fis));
            @SuppressWarnings("unchecked")
            List<Command> commands = (List<Command>) ois.readObject();
            ois.close();
            return commands;
        } catch (Exception e) {
            LOGGER.error("readCommands error!", e);
            return null;
        }
    }
}

(5)客户端

public class Client {
    public static void main(String args[]) {
        OperatorWindow window = new OperatorWindow(); // 请求发送者
        Command command; // 命令对象
        Operator operator = new Operator(); // 请求接收者

        // 具体命令
        command = new InsertCommand("insert");
        command.setOperator(operator);
        window.setCommand(command);
        window.call("节点1");

        command = new InsertCommand("insert");
        command.setOperator(operator);
        window.setCommand(command);
        window.call("节点2");

        command = new ModifyCommand("modify");
        command.setOperator(operator);
        window.setCommand(command);
        window.call("节点1");

        command = new DeleteCommand("delete");
        command.setOperator(operator);
        window.setCommand(command);
        window.call("节点2");

        System.out.println("---------------------保存操作记录---------------------");
        window.save();

        System.out.println("---------------------死机---------------------");

        System.out.println("---------------------恢复操作---------------------");
        window.recover();
    }
}

结果:
insert operation: 节点1
insert operation: 节点2
update operation: 节点1
delete peration: 节点2
---------------------保存操作记录---------------------
---------------------死机---------------------
---------------------恢复操作---------------------
insert operation: 节点1
insert operation: 节点2
update operation: 节点1
delete peration: 节点2

5.5、请求排队

其实和请求日志差不多,就不列代码了。

六、优点和缺点

6.1、优点

  • 降低系统的耦合度。
  • 新的命令可以很容易地加入到系统中。
  • 可以比较容易地设计一个命令队列和宏命令(宏命令又称为组合命令,它是组合模式和命令模式联用的产物。宏命令是一个具体命令类,它拥有一个集合属性,在该集合中包含了对其他命令对象的引用。当调用宏命令的execute()方法时,将递归调用它所包含的每个成员命令的execute()方法。)。
  • 可以方便地实现对请求的Undo和Redo。

6.2、缺点

使用命令模式可能会导致某些系统有过多的具体命令类。因为针对每一个命令都需要设计一个具体命令类,因此某些系统可能需要大量具体命令类,这将影响命令模式的使用。

七、适用环境

在以下情况下可以使用命令模式:

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

八、模式应用

  • 很多系统都提供了宏命令功能,如UNIX平台下的Shell编程,可以将多条命令封装在一个命令对象中,只需要一条简单的命令即可执行一个命令序列,这也是命令模式的应用实例之一。

九、总结

  • 在命令模式中,将一个请求封装为一个对象,从而使我们可用不同的请求对客户进行参数化;对请求排队或者记录请求日志,以及支持可撤销的操作。命令模式是一种对象行为型模式,其别名为动作模式或事务模式。
  • 命令模式包含四个角色:抽象命令类中声明了用于执行请求的execute()等方法,通过这些方法可以调用请求接收者的相关操作;具体命令类是抽象命令类的子类,实现了在抽象命令类中声明的方法,它对应具体的接收者对象,将接收者对象的动作绑定其中;调用者即请求的发送者,又称为请求者,它通过命令对象来执行请求;接收者执行与请求相关的操作,它具体实现对请求的业务处理。
  • 命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。命令模式使请求本身成为一个对象,这个对象和其他对象一样可以被存储和传递。
  • 命令模式的主要优点在于降低系统的耦合度,增加新的命令很方便,而且可以比较容易地设计一个命令队列和宏命令,并方便地实现对请求的撤销和恢复;其主要缺点在于可能会导致某些系统有过多的具体命令类。
  • 命令模式适用情况包括:需要将请求调用者和请求接收者解耦,使得调用者和接收者不直接交互;需要在不同的时间指定请求、将请求排队和执行请求;需要支持命令的撤销操作和恢复操作,需要将一组操作组合在一起,即支持宏命令。

推荐阅读更多精彩内容