设计模式——6、命令模式
  • 作者:ZJWave
  • 分类: 设计模式
  • 发表:2019-04-08 17:32
  • 围观:4607
  • 评论:0

在本文,我们将把封装带到一个全新的境界:把方法调用(method invocation)封装起来。没错,通过封装方法调用,我们可以把运算块包装成形。所以调用此运算的对象不需要关心事情是如何进行的,只要知道如何使用包装成形的方法来完成它就可以。通过封装方法调用,也可以做一些很聪明的事情,例如记录日志,或者重复使用这些封装来实现撤销(undo)。

1.一个家电自动化遥控器设计

巴斯特家电自动化公司

巴斯特家电自动化公司

伊利诺伊州

未来城工业路1221号

您好!

最近Weather-O-Rama气象站CEO像我展示并简单介绍了新扩张的气象站。我必须说,我对于该软件架构的印象非常深刻,所以想邀请你为我们设计一个家电自动化遥控器的API。作为服务回报,我们将慷慨地提供给您巴斯特家电自动化公司的股票期权。

附上一个创新控制器的原型以供你研究。这个遥控器具有七个可编程的插槽(每个都可以指定到一个不同的家电装置),每个插槽都有对应的开关按钮。这个遥控器还具备一个整体的撤销按钮。

我也在光盘里附上一组Java类,这些类由多家厂商开发出来的,用来控制家电自动化装置,例如电灯、风扇、热水器、音响设备和其他类似的可控制装置。

希望你能够创建一组控制遥控器的API,让每个插槽都能够控制一个或一组装置。请注意,能够控制目前的装置和任何未来可能出现的装置,这一点是很重要的。

基于你帮Weather-O-Rama气象站所做的成果,我们知道您一定能把这个遥控器设计得很好!

期待看到你的设计。

诚挚的,

Bill “X-10” Thompson,CEO

让硬件解脱!让我们看看这个遥控器……

看一下厂商的类

看起来类好像不少,但接口各有差异。麻烦还不只是这样,这些类以后还会越来越多。所以设计一个遥控器API变得很有挑战性。让我们继续设计吧!

你的团队正在讨论如何设计这个遥控器API……

Sue:哈!有新的设计任务来了。根据我初次观察的结果,目前有一个附着开和关按钮的简单遥控器,还有一套五花八门的厂商类。

Mary:是的,有许多的类都具备on()和off()方法,除此之外,还有一些方法像是dim()、setTemperature()、setVolumn()、setDirection()。

Sue:还不只这样,听起来似乎将来还会有更多的厂商类,而且每个类还会有各式各样的方法。

Mary:我认为要把它看成分离的关注点,这很重要:遥控器应该知道如何解读按钮被按下的动作,然后发出正确的请求,但是遥控器不需要知道这些家电自动化的细节,或者如何打开热水器。

Sue:听起来好像是个不错的设计方式。但如果遥控器很笨,只知道如何做出一般的要求,那又怎能设计出让这个遥控器能够调用一些诸如打开电灯或者车库门的动作呢?

Mary:我不确定该怎么做,但是我们不必让遥控器知道太多厂商类的细节。

Sue:你的意思是……

Mary:我们不想让遥控器包含一大堆if语句,例如“if  slot1==Light, then light.on(),else if slot1 == Hottub then hottob.jetsOn()”。大家都知道这样的设计很糟糕。

Sue:我同意你的说法。只要有新的厂商进来,就必须修改代码,这会造成潜在的错误,而且工作没完没了。

Joe:嘿!我不相信听到了你们的对话。从刚开始,我们就努力地学习设计模式。有一个设计模式就叫做“命令模式”,可能对你们有帮助。

Mary:是吗?再多说一些来听听。

Joe:命令模式可将“动作的请求者”从“动作的执行者”对象中解耦。在你们的例子中,请求者可以是遥控器,而执行者对象就是厂商类其中之一的实例。

Sue:这怎么可能?怎么能将它们解耦?毕竟,当我按下按钮时,遥控器必须把电灯打开。

Joe:在你的设计中采用“命令对象”就可以办到。利用命令对象,把请求(例如打开电灯)封装成一个特定对象(例如客厅电灯对象)。所以,如果对每个按钮都存储一个命令对象,那么当按钮被按下的时候,就可以请命令对象做相关的工作。遥控器并不需要知道工作内容是什么,只要有个命令对象能和正确的对象沟通,把事情做好就可以了。所以,看吧,遥控器和电灯对象解耦了。

Sue:的确听起来像是一个正确的方向。

Mary:我仍然无法理解这个模式怎么工作。

Joe:由于对象之间是如此的解耦。要描述这个模式实际的工作并不容易。

Mary:听听我的想法是否正确:使用这个模式,我们能够创建一个API,讲这些命令对象加载到按钮插槽,让遥控器的代码尽量保持简单。而把家电自动化的工作和进行该工作的对象一起封装在命令对象中。

Joe:是的,我也这么认为。我也认为这个模式可以同时帮你设计“赊销按钮”,但我还没研究到这部分。

Mary:听起来令人振奋,但我想应该还要好好学习这个模式。

Sue:我也是。

1.1 命令模式的简单介绍

如果Joe所说的,仅仅通过听别人口述的方式来了解命令模式,确实有点困难。但是别害怕,有一些朋友正准备帮助我们:

让我们看看一个餐厅中的顾客、女招待、订单,以及快餐厨师之间是怎么交互的。通过这样的互动,你将体会到命令模式所涉及的对象,也会知道它们之间如何被解耦。之后,我们就可以解决遥控器API了。

进入对象村餐厅……

  1. 你,也就是顾客,把订单交给女招待
  2. 女招待拿了订单,放在订单柜台,然后喊了一声“订单来了!”
  3. 快餐厨师根据订单准备餐点。

……既然餐厅是在对象村,所以让我们也来思考对象和方法的调用关系:

对象村餐厅的角色和职责

一张订单封装了准备餐点的请求。

把订单想象成一个用来请求准备餐点的对象,和一般的对象一样,订单对象可以被传递:从女招待传递到订单柜台,或者从女招待传递到接替下一班的女招待。订单的接口只包含一个方法,也就是orderUp()。这个方法封装了准备餐点所需的动作。订单内有一个到“需要进行准备工作的对象”(也就是厨师)的引用。这一切都被封装起来,所以女招待不需要知道订单上有什么,也不需要知道是谁来准备餐点,她只需要将订单放到订单窗口,然后喊一声“订单来了”就可以了。

女招待的工作是接受订单,然后调用订单的orderUp()方法。

女招待的工作很简单:接下顾客的订单,继续帮助下一个顾客,然后将一定数量的订单放到订单柜台,并调用orderUp()方法,让人来准备餐点。女招待其实不必知道订单的内容是什么,或者由谁来准备餐点。她只需要知道,订单有一个orderUp()方法可以调用,这就够了。

现在,一天内,不同的顾客有不同的订单,这会使得女招待的takeOrder()方法被传入不同的参数。女招待知道所有的订单都支持orderUp()方法,任何时候她需要准备餐点时,调用这个方法就是了。

快餐厨师具备准备餐点的知识。

快餐厨师是一种对象,他真正知道如何准备餐点。一旦女招待orderUp()方法,快餐厨师就接手,实现需要创建餐点的所有方法。请注意,女招待和厨师之间是彻底的解耦:女招待的订单封装了餐点的细节,她只要调用每个订单的方法即可,而厨师看了订单就知道该做些什么餐点,厨师和女招待之间从来不需要直接沟通。


把餐厅想成是OO设计模式的一种模型,而这个模型允许将“发出请求的对象”和“接受与执行这些请求的对象”分隔开来。比方说,对于遥控器API,我们需要分隔开“发出请求的按钮代码”和“执行请求的厂商特定对象”。万一遥控器的每个插槽都持有一个像餐厅订单那样的对象,会怎么样?那么,当一个按钮被按下,只要调用该对象的orderUp()方法,电灯就开了,而遥控器不需要知道事情是怎么发生的,也不需要知道涉及哪些对象。

现在我们就把餐厅的对象换成命令模式……

从餐厅到命令模式

好了,我们已经花费了很多时间在对象村餐厅,也清楚地知道各种角色的特性和他们的职责。现在我们要重新绘制餐厅图以反映出命令模式。所有的角色依然不变,只有名字变了。

2.第一个命令对象

是我们建立第一个命令对象的时候了!现在开始写一些遥控器的代码。虽然我们还没搞清楚如何设计遥控器的API,但自下而上建造一些东西,可能会有帮助……

实现命令接口

首先,让所有命令对象实现相同的包含一个方法的接口。在餐厅的例子中,我们称此方法为orderUp(),然而,现在改为一般惯用的名称execute()。这就是命令接口:

public interface Command {

    // 简单!只需要一个方法:execute()。
    void execute();
}

实现一个打开电灯的命令

现在,假设想实现一个打开电灯的命令。根据厂商所提供的类,Light类由两个方法:on()和off()。下面是如何将它实现成一个命令:

/**
 * 这是一个命令,所以需要实现Command接口。
 */
public class LightOnCommand implements Command {
    
    Light light;
    
    //构造器被传入了某个电灯(比方说:客厅的电灯),以便让这个命令控制,
    //然后记录在实例变量中。一旦调用execute(),就由这个电灯对象成为接受者,
    //负责接受请求。
    public LightOnCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        //这个execute()方法调用接收对象(我们正在控制的电灯的on()方法。)
        light.on();
    }
}

现在有了LightOnCommand类,让我们看看如何使用它……

使用命令对象

好了,让我们把这一切简化:假设我们有一个遥控器,它只有一个按钮和对应的插槽,可以控制一个装置:

public class SimpleRemoteControl {

    //有一个插槽持有命令,而这个命令控制着一个装置。
    Command slot;

    public SimpleRemoteControl() {
    }

    //这个方法用来设置插槽控制的命令。
    //如果这段代码的客户想要改变遥控器按钮的行为,
    //可以多次调用这个方法。
    public void setCommand(Command slot) {
        this.slot = slot;
    }

    //当按下按钮时,这个方法就会被调用,
    //使得当前命令衔接插槽,并调用它的execute()方法
    public void buttonWasPressed(){
        slot.execute();
    }
}

遥控器使用的简单测试

下面只有一点点代码,用来测试上面的简单遥控器。我们来看看这个代码,并指出它和命令模式图的对应关系:

/**
 * 这是命令模式的客户
 */
public class RemoteControlTest {

    public static void main(String[] args) {
        //遥控器就是调用者,会传入一个命令对象,可以用来发出请求。
        SimpleRemoteControl remote = new SimpleRemoteControl();
        //现在创建了一个电灯对象,此对象也就是请求的接收者
        Light light = new Light();
        //在这里创建一个命令,然后将接收者传给它。
        LightOnCommand lightOn = new LightOnCommand(light);

        //把命令传给调用者。
        remote.setCommand(lightOn);
        //然后模拟按下按钮
        remote. buttonWasPressed();
    }

}

3.定义命令模式

在经过对象村餐厅的学习之后,你已经实现了部分的遥控器API,而且在这个过程中,你也对命令模式内的类和对象是如何互动的理解得很清楚了。现在,我们就来定义命令模式,并敲定所有的细节。

先从正式的定义开始:

命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象。

现在,仔细看这个定义。我们知道一个命令对象通过在特定接收者上绑定一组动作来封装一个请求。要达到这一点,命令对象将动作和接收者包进对象中。这个对象只暴露出一个execute()方法,当此方法被调用的时候,接收者就会进行这些动作。从外面来看,其他对象不知道究竟哪个接收者进行了哪些动作,只知道如果调用execute()方法,请求的目的就能达到。

我们也看到了利用命令来参数化对象的一些例子。再回到餐厅,一整天下来,女招待参数化许多订单。在简单遥控器中,我们先用一个“打开电灯”命令加载按钮插槽,稍后又将命令替换称为另一个“打开车库门”命令。就和女招待一样,遥控器插槽根本不在乎所拥有的是什么命令对象,只要该命令对象实现了Command接口就可以了。

我们还未说到命令模式来实现“队列、日志和支持撤销操作”。别担心,这是基本命令模式相当直接的扩展,很快我们就会看到这些内容。一旦有了足够的基础,也可以轻易地持所谓的Meta Command Pattern。Meta Command Pattern可以创建命令的宏,以便一次执行多个命令。

3.1 命令模式类图

Sue:好了,我已经能体会命令模式了。Joe,谢谢你介绍这个技巧,我想在完成这个遥控器API之后,我们会被视为超级巨星。

Mary:我也这么觉得。那么,应该从哪里开始?

Sue:就像我们在简单遥控器(SimpleRemote)中所做的一样,我们需要提供一个方法,将命令指定到插槽。实际上,我们有7个插槽,每个插槽都具备了“开”和“关”按钮,所以我们可以用类似方式,把命令指定给遥控器,像这样:

onCommands[0] = onCommand;
offCommands[0] = offCommand;

Mary:很有道理,但电灯对象应该排除。遥控器如何分辨客厅或厨房的电灯?

Sue:喔,对了,遥控器无法区分这些!遥控器除了在按下按钮时,调用对应命令对象的execute()方法之外,它什么都不知道。

Mary:是的,这个我似乎了解,但是在实现时,如何确定对象打开(或关闭)正确的装置?

Sue:当我们创建命令并将其加载到遥控器时,我们创建的命令是两个LightCommand,其中一个绑定到客厅电灯对象,另一个则绑定到厨房的电灯对象。别忘了,命令中封装了请求的接收者。所以,在按下按钮时,根本不需要理会打开哪一个电灯,只要execute()被调用,该按钮的对应对象就有动作。

Mary:我想我懂了。现在开始实现这个遥控器吧!我认为一切都会越来越清楚。

Sue:听起来很棒,开工了……

4.自动化遥控器的实现

4.1 将命令指定到插槽

我们的计划是这样的:我们打算将遥控器的每个插槽,对应到一个命令这样就让遥控器变成“调用者”。当按下按钮,相应命令对象的execute()方法就会被调用,其结果就是,接收者(例如:电灯、天花板电扇、影响)的动作被调用。

4.2 实现遥控器

public class RemoteControl {
    //这个时候,遥控器要处理7个开与关的命令,
    //使用相应数组记录这些命令。
    Command[] onCommands;
    Command[] offCommands;

    public RemoteControl() {
        //在构造其中,只需实例化并初始化这两个开与关的数组。
        onCommands = new Command[7];
        offCommands = new Command[7];

        NoCommand noCommand = new NoCommand();
        for (int i = 0; i < 7; i++) {
            onCommands[i] = noCommand;
            offCommands[i] = noCommand;
        }
    }

    /**
     * 这个方法必须有三个参数,分别是
     * 插槽的位置、开的命令、关的命令。这些命令将记录在
     * 开关数组中对应的插槽位置,以供稍后使用。
     * @param slot
     * @param onCommand
     * @param offCommand
     */
    public void setCommand(int slot,Command onCommand,Command offCommand){
        onCommands[slot] = onCommand;
        offCommands[slot] = offCommand;
    }

    /**
     * 当按下开或关的按钮,硬件就会负责调用对应的方法,
     * 也就是onBUttonWasPushed()或
     * offButtonWasPushed()
     * @param slot
     */
    public void onButtonWasPushed(int slot){
        onCommands[slot].execute();
    }

    public void offButtonWasPushed(int slot){
        offCommands[slot].execute();
    }

    /**
     * 覆盖toString(),打印出每个插槽和它对应的命令。
     * 稍后在测试遥控器的时候,会用到这个方法。
     * @return
     */
    @Override
    public String toString() {
        StringBuilder buff = new StringBuilder();
        buff.append("\n------ Remote Control -------\n");
        for (int i = 0; i < onCommands.length; i++) {
            buff.append("[slot " + i + "] " + onCommands[i].getClass().getSimpleName() +
                    "    " + offCommands[i].getClass().getSimpleName() + "\n");
        }
        return buff.toString();
    }
}

4.3 实现命令

我们已经为SimpleRemoteControl(简单遥控器)动手实现过LightOnCommand,我们可以将相同的代码应该在这里,一切都能顺利进行。关闭命令并没有什么不同,事实上,LightOffCommand看起来就像这样:

public class LightOffCommand implements Command {

    Light light;

    public LightOffCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        //LightOffCommand工作方式和LightOnCommand一样,
        //只是调用不同的方法,也就是off()方法
        light.off();
    }
}

让我们来提高挑战性,如何为音响(Stereo)编写开与关的命令?好了,关是很容易,只要把Stereo绑定到StereoOffCommand的off()方法就可以了。开就有点复杂,假设我们要写一个StereoOnWithCDCommand……

public class StereoOnWithCDCommand implements Command {

    Stereo stereo;

    /**
     * 就如同LightOnCommand的做法一样,
     * 传入音响的实例,然后将其储存在局部实例变量中。
     * @param stereo
     */
    public StereoOnWithCDCommand(Stereo stereo) {
        this.stereo = stereo;
    }

    /**
     * 要实现这个请求,需要调用音响是三个方法:
     * 首先打开它,
     * 然后把它设置成播放CD,
     * 最后把音量设为11。
     */
    @Override
    public void execute() {
        stereo.on();
        stereo.setCD();
        stereo.setVolume(11);
    }
}

这一切还不错。看看剩下的厂商类,此刻,相信你已经有能力可以完成剩下的命令类了。

4.4 逐步测试遥控器

遥控器的工作差不多已经完成,我们剩下要做的事情是运行测试和准备API的说明文档。巴斯特家庭自动化公司一定对我们的成果感到印象深刻,不是吗?我们打算呈现一个绝佳的设计,让他们能够生产易于维护的遥控器。将来,他们也将很容易说服厂商,写一些简单的命令类,因为它们写起来很简单。

开始测试这份代码吧!

public class RemoteLoader {

    public static void main(String[] args) {
        RemoteControl remoteControl = new RemoteControl();

        //将所有的装置创建在合适的位置
        Light livingRoomLight = new Light("Living Room");
        Light kitchenLight = new Light("Kitchen");
        CeilingFan ceilingFan = new CeilingFan("Living Room");
        GarageDoor garageDoor = new GarageDoor("");
        Stereo stereo = new Stereo("Living Room");

        //创建所有的电灯命令对象
        LightOnCommand livingRoomLightOn = new LightOnCommand(livingRoomLight);
        LightOffCommand livingRoomLightOff = new LightOffCommand(livingRoomLight);
        LightOnCommand kitchenLightOn = new LightOnCommand(kitchenLight);
        LightOffCommand kitchenLightOff = new LightOffCommand(kitchenLight);

        //创建吊扇的开与关命令
        CeilingFanOnCommand ceilingFanOn = new CeilingFanOnCommand(ceilingFan);
        CeilingFanOffCommand ceilingFanOff = new CeilingFanOffCommand(ceilingFan);

        //创建车库门的上与下命令
        GarageDoorUpCommand garageDoorUp = new GarageDoorUpCommand(garageDoor);
        GarageDoorDownCommand garageDoorDown = new GarageDoorDownCommand(garageDoor);

        //创建音响的开与关命令
        StereoOnWithCDCommand stereoOnWithCD = new StereoOnWithCDCommand(stereo);
        StereoOffCommand stereoOff = new StereoOffCommand(stereo);

        //现在已经有了全部的命令,可以将它们加载到遥控器插槽中
        remoteControl.setCommand(0,livingRoomLightOn,livingRoomLightOff);
        remoteControl.setCommand(1,kitchenLightOn,kitchenLightOff);
        remoteControl.setCommand(2,ceilingFanOn,ceilingFanOff);
        remoteControl.setCommand(3,stereoOnWithCD,stereoOff);

        //在这里,使用toString()方法,打印出每个遥控器的插槽和它被指定的命令。
        System.out.println(remoteControl);

        //一切准备就绪!现在逐步按下每个插槽的开与关按钮
        remoteControl.onButtonWasPushed(0);
        remoteControl.offButtonWasPushed(0);
        remoteControl.onButtonWasPushed(1);
        remoteControl.offButtonWasPushed(1);
        remoteControl.onButtonWasPushed(2);
        remoteControl.offButtonWasPushed(2);
        remoteControl.onButtonWasPushed(3);
        remoteControl.offButtonWasPushed(3);
    }
}

萌新:等一下,插槽4到插槽6写着“NoCommand”,这是怎么回事?想要糊弄我吗?

被你发现了。我们的确省略了一些东西。在遥控器中,我们不想每次都检查是否某个插槽都加在了命令。比方说,在这onButtonWasPushed()方法中,我们可能需要这样的代码:

public void onButtonWasPushed(int slot){
    if(onCommands[slot] != null){
        onCommands[slot].execute();
    }
}

所以,我们要如何避免上述的做法?实现一个不做事情的命令!

public class NoCommand implements Command {
    @Override
    public void execute() {}
}

这么一来,在RemoteControl构造器中,我们将每个插槽都预先指定成NoCommand对象,以便确定每个插槽永远都有命令对象。

NoCommand noCommand = new NoCommand();
for (int i = 0; i < 7; i++) {
    onCommands[i] = noCommand;
    offCommands[i] = noCommand;
}

所以在测试的输出中,没有被明确指定命令的插槽,其命令将是默认的NoCommand对象。

NoCommand对象是一个空对象(null object)的例子。当你不想返回一个有意义的对象时,空对象就很有用。客户也可以将处理null的责任转移给空对象。举例来说,遥控器不可能一出场就设置了有意义的命令对象,所以提供了NoCommand对象作为代用品,当调用它的execute()方法时,这种对象什么事情都不做。

在许多设计模式中,都会看到空对象的使用。甚至有些时候,空对象本身也被视为是一种设计模式。

4.5 写文档的时候到了

为巴斯特家电自动化公司设计的遥控器API

我们很高兴为您呈现下列的家电自动化遥控器设计与应用编程接口。主要的设计目标是让遥控器代码尽可能地简单,这样一来,新的厂商类一旦出现,遥控器并不需要随之修改。因此,我们才用了命令模式,从逻辑上将遥控器的类和厂商的类解耦。我们相信这将降低遥控器的生产成本,并大大地减少未来维护时所需的费用。

下面的类图提供了设计的全貌:

4.6 实现撤销功能

因为我们采用基本命令类,所以可以很容易地加上撤销的功能。让我们逐步为遥控器加上撤销的功能。这个功能使用起来就像是这样的:比方说客厅的电灯是关闭的,然后你按下遥控器上的开启按钮,自然电灯就被打开了。现在如果按下撤销按钮,那么上一个动作将被倒转,在这个例子里,电灯将被关闭。在进入更复杂的例子之前,先让撤销按钮能够处理电灯:

① 当命令支持撤销时,该命令就必须提供和execute()方法相反的undo()方法。不管execute()刚才做什么,undo()都会倒转过来。这么一来,在各个命令中加入undo()之前,我们必须现在Command接口中加入undo()方法:

public interface Command {
    void execute();
    //这是新加入的undo()方法。
    void undo();
}

这实在是够简单。

现在让我们深入电灯的命令,并实现undo()方法。

② 我们从LightOnCommand开始下手:如果LightOnCommand的execute()方法被调用,那么最后被调用的是on()方法。我们知道undo()需要调用off()方法进行相反的动作。

public class LightOnCommand implements Command {

    Light light;

    public LightOnCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.on();
    }

    @Override
    public void undo() {
        //execute()打开电灯,所以undo该做的事情就是关闭电灯。
        light.off();
    }
}

太容易了!现在来处理LightOffCommand,在这里,undo()方法需要调用电灯的on()方法。

public class LightOffCommand implements Command {

    Light light;

    public LightOffCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.off();
    }

    @Override
    public void undo() {
        //在这里,undo()把电灯打开
        light.on();
    }
}

实在是简单到不行,事情可还没完,我们还要花一些力气,让遥控器能够追踪最后被按下的按钮是什么。

③ 要加上对撤销按钮的支持,我们必须对遥控器类做一些小修改。我们打算这么做:加入一个新的实例变量,用来追踪最后被调用的命令,然后,不管何时撤销按钮被按下,我们都可以取出这个命令并调用它的undo()方法。

public class RemoteControlWithUndo {
    Command[] onCommands;
    Command[] offCommands;
    //前一个命令将被记录在这里
    Command undoCommand;

    public RemoteControlWithUndo() {
        onCommands = new Command[7];
        offCommands = new Command[7];

        NoCommand noCommand = new NoCommand();
        for (int i = 0; i < 7; i++) {
            onCommands[i] = noCommand;
            offCommands[i] = noCommand;
        }
        //一开始,并没有所谓的“前一个命令”,所以将它设置成NoCommand对象
        undoCommand = noCommand;
    }

    public void setCommand(int slot,Command onCommand,Command offCommand){
        onCommands[slot] = onCommand;
        offCommands[slot] = offCommand;
    }

    public void onButtonWasPushed(int slot){
        onCommands[slot].execute();
        //当按下按钮,我们取得这个命令并优先执行它,然后将它记录在undoCommand
        //实例变量中。不管是“开”或“关”命令,我们的处理方法都是一样的。
        undoCommand = onCommands[slot];
    }

    public void offButtonWasPushed(int slot){
        offCommands[slot].execute();
        undoCommand = offCommands[slot];
    }

    public void undoButtonWasPushed(){
        //当按下撤销按钮,我们调用undoCommand实例变量的undo()
        //方法,就可以倒转前一个命令。
        undoCommand.undo();
    }

    @Override
    public String toString() {
        StringBuilder buff = new StringBuilder();
        buff.append("\n------ Remote Control -------\n");
        for (int i = 0; i < onCommands.length; i++) {
            buff.append("[slot " + i + "] " + onCommands[i].getClass().getSimpleName() +
                    "    " + offCommands[i].getClass().getSimpleName() + "\n");
        }
        return buff.toString();
    }
}

好了,让我们修改测试程序,测试撤销按钮

public class RemoteLoader {

    public static void main(String[] args) {
        RemoteControlWithUndo remoteControl = new RemoteControlWithUndo();

        //创建一个电灯对象和新支持undo()功能的命令。
        Light livingRoomLight = new Light("Living Room");

        LightOnCommand lightOn = new LightOnCommand(livingRoomLight);
        LightOffCommand lightOff = new LightOffCommand(livingRoomLight);

        //将电灯命令设置到遥控器的0号插槽。
        remoteControl.setCommand(0,lightOn,lightOff);

        //打开电灯,关闭电灯,然后撤销。
        remoteControl.onButtonWasPushed(0);
        remoteControl.offButtonWasPushed(0);
        System.out.println(remoteControl);
        remoteControl.undoButtonWasPushed();
        //关闭电灯,打开电灯,然后撤销。
        remoteControl.offButtonWasPushed(0);
        remoteControl.onButtonWasPushed(0);
        System.out.println(remoteControl);
        remoteControl.undoButtonWasPushed();
    }
}

使用状态实现撤销

好了,实现电灯的撤销是有意义的,但也实在是太容易了。通常,想要实现撤销的功能,需要记录一些状态。让我们试一个更有趣的例子,比方说厂商类中的天花板上的吊扇。吊扇允许有多种转动速度,当然也允许被关闭。

吊扇的源码如下:

public class CeilingFan{

    public static final int HIGH = 3;
    public static final int MEDIUM = 2;
    public static final int LOW = 1;
    public static final int OFF = 0;

    String location;

    int speed;

    public CeilingFan(String location) {
        this.location = location;
        speed = OFF;
    }

    public void high(){
        speed = HIGH;
        //设置高转速
        System.out.println(location + " ceiling fan is on high");
    }

    public void medium(){
        speed = MEDIUM;
        //设置中转速
        System.out.println(location + " ceiling fan is on medium");
    }

    public void low(){
        speed = LOW;
        //设置低转速
        System.out.println(location + " ceiling fan is on low");
    }

    public void off(){
        speed = OFF;
        // 关闭吊扇
        System.out.println(location + " ceiling fan is off");
    }

    public int getSpeed(){
        //可以利用getSpeed()方法得到吊扇的当前速度
        return speed;
    }

}

加入撤销到吊扇的命令类

现在就让我们把撤销加入天花板吊扇的诸多命令中。这么做,需要追踪吊扇的最后设置速度,如果undo()方法被调用了,就要恢复成之前吊扇速度的设置值。下面是CeilingFanHightCommand的代码:

public class CeilingFanHighCommand implements Command {

    CeilingFan ceilingFan;
    int prevSpeed;

    public CeilingFanHighCommand(CeilingFan ceilingFan) {
        this.ceilingFan = ceilingFan;
    }

    @Override
    public void execute() {
        prevSpeed = ceilingFan.getSpeed();
        //在execute()中,在外面改变吊扇的速度值钱,需要先将它
        //之前的状态记录起来,以便需要撤销时使用。
        ceilingFan.high();
    }

    @Override
    public void undo() {
        //将吊扇的速度设置回之前的值,达到撤销的目的
        if(prevSpeed == CeilingFan.HIGH){
            ceilingFan.high();
        }else if(prevSpeed == CeilingFan.MEDIUM){
            ceilingFan.medium();
        }else if(prevSpeed == CeilingFan.LOW){
            ceilingFan.low();
        }else if(prevSpeed == CeilingFan.OFF){
            ceilingFan.off();
        }
    }
}

我们用同样的方法可以写出剩下的low、medium、off天花板命令。

准备测试天花板吊扇

该是测试天花板吊扇的时候了。我们打算把第0号插槽的开启按钮设置为中速,把第1号插槽的开启按钮设置为告诉,而两个对应的关闭按钮,都是关闭吊扇的命令。

测试代码如下:

public class RemoteLoader {

    public static void main(String[] args) {
        RemoteControlWithUndo remoteControl = new RemoteControlWithUndo();

        CeilingFan ceilingFan = new CeilingFan("Living Room");

        //在这里实例化了三个命令,分别是:高速、中速和关闭。
        CeilingFanMediumCommand ceilingFanMedium = new CeilingFanMediumCommand(ceilingFan);
        CeilingFanHighCommand ceilingFanHigh = new CeilingFanHighCommand(ceilingFan);
        CeilingFanOffCommand ceilingFanOff = new CeilingFanOffCommand(ceilingFan);

        //在这里将中速设置到0号插槽,将告诉设置到第一号插槽,
        //并加载这两个插槽的关闭命令
        remoteControl.setCommand(0,ceilingFanMedium,ceilingFanOff);
        remoteControl.setCommand(1,ceilingFanHigh,ceilingFanOff);

        //首先,以中速开启吊扇。
        remoteControl.onButtonWasPushed(0);
        //然后关闭
        remoteControl.offButtonWasPushed(0);
        System.out.println(remoteControl);
        //撤销,应该会回到中速
        remoteControl.undoButtonWasPushed();

        //这个时候开启高速
        remoteControl.onButtonWasPushed(1);
        System.out.println(remoteControl);
        //再进行一次撤销,应该会回到中速
        remoteControl.undoButtonWasPushed();
    }
}

4.7 每个遥控器都具备“Party模式”

如果拥有了一个遥控器,却无法光凭按下一个按钮,就同时能弄暗灯光、打开音响和电视、设置好DVD,并让热水器开始加温,那么要这个遥控器还有什么意义?

Sue:嗯!我们的遥控器需要为每个装置准备一个按钮,我不认为可以做到上面的要求。

Mary:等一下,Sue,这可不一定。我认为可以做到这一点,而且完全不需要改变遥控器。

Mary的想法是,制造一种新的命令,用来执行其他一堆命令……而不只是执行一个命令!这个想法不错吧?

public class MacroCommand implements Command {

    Command[] commands;

    //在宏命令中,用命令数组存储一大堆命令。
    public MacroCommand(Command[] commands) {
        this.commands = commands;
    }

    @Override
    public void execute() {
        //当这个宏命令被遥控器执行时,就一次性执行数组里的每个命令
        for (Command command : commands) {
            command.execute();
        }
    }

    @Override
    public void undo() {

    }
}

4.8 使用宏命令

让我们逐步来看如何使用宏命令:

① 先创建想要进入宏的命令集合:

//创建所有的装置,
//电灯、电视、音响和热水器。
Light light = new Light("Living Room");
TV tv = new TV("Living Room");
Stereo stereo = new Stereo("Living Room");
Hottub hottub = new Hottub();

//现在,创建所有的On命令来控制它们
LightOnCommand lightOn = new LightOnCommand(light);
StereoOnCommand stereoOn = new StereoOnCommand(stereo);
TVOnCommand tvOn = new TVOnCommand(tv);
HottubOnCommand hottubOn = new HottubOnCommand(hottub);

② 接下来创建两个数组,其中一个用来记录开启命令,另一个用来记录关闭命令,并在数组内放入对应的命令。

//一个数组用来记录开启命令,
//另一个数组用来记录关闭命令……
Command[] partyOn = {lightOn,stereoOn,tvOn,hottubOn};
Command[] partyOff = {lightOff,stereoOff,tvOff,hottubOff};

//然后创建两个对应的宏持有他们
MacroCommand paytyOnMacro = new MacroCommand(partyOn);
MacroCommand partyOffMacro = new MacroCommand(partyOff);

③ 然后将宏命令指定给我们所希望的按钮:

//将宏命令指定给一个按钮
remoteControl.setCommand(0,paytyOnMacro,partyOffMacro);

④ 最后,只需按下一些按钮,测试是否正常工作。

System.out.println(remoteControl);
System.out.println("--- Pushing Macro On---");
remoteControl.onButtonWasPushed(0);
System.out.println("--- Pushing Macro Off---");
remoteControl.offButtonWasPushed(0);

5.命令模式Q&A

 

 

问:接收者一定有必要存在吗?为何命令对象不直接实现execute()方法的细节?

答:一般来说,我们尽量设计“傻瓜”命令对象,它只懂得调用一个接收者的一个行为。然而,有许多“聪明”命令对象会实现许多逻辑,直接完成一个请求。当然你可以设计聪明的命令对象,只是这样一来,调用者和接收者之间的解耦程度是比不上“傻瓜”命令对象的,而且,你也不能够把接收者当做参数传给命令。

问:我如何能够实现多层次的撤销操作?换句话说,我希望能够按下撤销按钮许多次,撤销到很早很早以前的状态。

答:好问题!其实这相当容易做到,不要只是记录最后一个被执行的命令。然后,不管什么时候按下了撤销按钮,你都可以从堆栈中取出最上层的命令,然后调用它的undo()方法。

问:我可以创建一个PartyCommand,然后在它的execute()方法中调用其他的命令,利用这种做法实现Payty模式(Party Mode)吗?

答:你可以这么做。然而,这等于把Party模式“硬编码”到PartyCommand中。为什么要这么麻烦呢?利用宏命令,你可以动态地决定PartyCommand是由哪些命令组成,所以宏命令在使用上更灵活。一般来说,宏命令的做法更优雅,也需要较少的新代码。

6.命令模式的更多用途

6.1 队列请求

命令可以将运算块打包(一个接收者和一组动作),然后将它传来传去,就像是一般的对象一样。现在,即使在命令对象被创建许久以后,运算依然可以被调用。我们可以利用这样的特性衍生一些应用,例如:日程安排(Scheduler)、线程池、工作队列等。

想象有一个工作队列:你在某一端添加命令,然后另一端则是线程。线程进行下面的动作:从队列中取出一个命令,调用它的execute()方法,等待这个调用完成,然后将此命令对象丢弃,再取出下一个命令……

请注意,工作队列类和进行计算的对象之间完全是解耦的。此刻线程可能在进行财务运算,下一刻却在读取网络数据。工作队列对象不在乎到底做些什么,它们只知道取出命令对象,然后调用其execute()方法。类似地,它们只要是实现命令模式的对象,就可以放入队列里,当线程可用时,就调用此对象的execute()方法。

6.2 日志请求

某些应用需要我们将所有的动作都记录在日志中,并能在系统死机之后,重新调用这些动作恢复到之前的状态。通过新增两个方法(store()、load()),命令模式就能够支持这一点。在Java中,我们可以利用对象的序列化(Serialization)实现这些方法,但是一般认为序列化最好还是只用在对象的持久化上(persistence)。

要怎么做呢?当我们执行命令的时候,将历史记录储存在磁盘中。一旦系统死机,我们就可以将命令对象重新加载,并成批地一次调用这些对象的execute()方法。

这种日志的方式对于遥控器来说没有意义,然而,有许多调用大型数据结构的动作的应用无法在每次改变发生时被快速地存储。通过使用记录日志,我们可以将上次检查点(checkpoint)之后的所有操作记录下来,如果系统出状况,从检查点开始应用这些操作。比方说,对于电子表格应用,我们可能想要实现的错误恢复方式是将电子表格的操作记录在日志中,而不是每次电子表格一有变化就记录整个电子表格。对更高级的应用而言,这些技巧可以被扩展应用到事务(transaction)处理中,也就是说,一整群操作必须全部进行完成,或者没有进行任何的操作。

  1. 我们加入两个新方法:store()和load()用来记录日志。
  2. 当每个命令被执行时,会被储存在磁盘中。
  3. 在系统死机后,这些对象会被重新加载,并以正确的次序执行。

7.总结

在本文,我们加入了一个设计模式,这个模式允许我们将动作封装成命令对象,这样一来就可以随心所欲地储存、传递和调用它们。

OO原则:

  • 封装变化。
  • 多用组合,少用继承。
  • 针对接口编程,不针对实现编程。
  • 为交互对象之间松耦合设计而努力。
  • 类应该对扩展开放,对修改关闭。
  • 以来抽象,不要依赖具体类。

OO模式:

  • 命令模式——将请求封装成对象,这可以让你使用不同的请求、队列,或者日志请求来参数化其他对象。命令模式也可以支持撤销操作。

本文要点:

  • 命令模式将发出请求的对象和执行请求的对象解耦。
  • 在被解耦的两者之间是通过命令对象进行沟通的,命令对象封装了接收者和一个或一组动作。
  • 调用者通过调用命令对象的execute()发出请求,这会使得接收者的动作被调用。
  • 调用者可以接受命令当参数,甚至在运行时动态地进行。
  • 命令可以支持撤销,做法是实现一个undo()方法来回到execute()被执行前的状态。
  • 宏命令是命令的一种简单的延伸,允许调用多个命令。宏方法也可以支持撤销。
  • 实际操作时,很常见使用“聪明”命令对象,也就是直接实现了请求,而不是将工作委托给接收者。
  • 命令也可以用来实现日志和事务系统。

转载请注明原文链接:ZJ-Wave

Top