代码改变世界

设计模式之命令模式

2012-10-03 17:59  youxin  阅读(300)  评论(0编辑  收藏  举报

命令模式(the command pattern)

在本章,我们把封装带到一个全新的境界:把方法调用method invocation封装起来。没错,通过封装方法调用,我们可以把运算块包装成型。所以调用此运算的对象不需要关心事情是如何进行的,只要知道如何使用包装成形的方法来完成它就可以。通过封装方法调用,也可以做一些很聪明的事情,如记录日志,或者重复使用这些封装来实现撤销(undo)。
 
我们有了 一个任务,weather-o-rama公司要我们设计一个遥控程序,遥控器具有7个可编程的插槽(每个可以指定到一个不同的家电装置),每个插槽都有相应的关开按钮,这个遥控器还具备一个整体的撤销按钮。
 还有一组java类,这些类是由多家厂商开发出来的,用来控制家电自动化装置,如电灯,热水器等等。
 希望你能够创建一组控制器的api,让每个插槽都能够控制一个或一组装置,这一点是很重要的的。
 
看一下厂商的类:

 

 

看起来类好像不少,但接口各有差异,麻烦还不只是这样,这些类以后还会越来越多,所以设计一个遥控器api变得很有挑战性。
 
讨论
 我不确定该怎么做,但是我们不必让遥控器知道太多厂商类的细节。
sorry:你的意思是:
mary:我们不想让遥控器包含一大堆if语句,例如if slot1==Light, then light.on() else if( slot1==Hottbub then hottob.jetsOn(); 大家都知道这样的设计很糟糕。
sue:我同意你的说法。只要有新的厂商类进来,我们就必须修改代码,这会造成潜在的错误 。
joe:有一个模式叫“命令模式”,可能会对你们有帮助。
Mary:是吗?说了听听
joe:命令模式可将“动作的请求者”从“动作的执行者”对象中解耦。在你们 的例子中,请求者是遥控器,执行者是厂商类其中之一的实例。
Sue:这怎么可能?怎么能将他们解耦?毕竟,当我按下按钮时,遥控器必须把电灯打开
joe:在你的设计中采用“命令对象”就可以办到。利用命令对象,把请求(例如打开电灯)封装成一个特定对象 (例如客厅电灯对象)。所以,如果对每个按钮都存储一个命令对象,那么当按下按钮时,就可以请命令对象做相关的工作。遥控器 并不需要知道工作是什么,只要有个命令对象能和正确的对象沟通,把事情做好就可以了。所以,看吧!遥控器和电灯对象解耦了。
 
回到命令模式的简单介绍。餐厅工作流程:

 

让我们更详细的研究这个交互过程,

 对象村餐厅的角色和职责
一张订单封装了准备餐点的请求
  把订单想象成一个用来请求准备餐点的对象,和一般的对象一样,订单对象可以被传递:从女招待传递到订单柜台,或者从女招待传递到接替下一班的女招待。订单的接口只包含一个方法:orderUp(),这个方法封装了准备餐点所需的动作。
 
女招待的工作是接受订单,然后调用orderUP方法
l
快餐厨师具备准备餐点的知识。
 
从餐厅到命令模式
 

 

encapsulating invocation
餐厅    命令模式
顾客    client
订单    Command
女招待  invoker
takeOrder  setCommand
厨师  Receiver
orderUp  execute
 
client
|
|
|
invoker
|
|
|
receiver
 
第一个命令对象  
  实现命令接口
 
 让所有的命令对象实现相同的包含一个方法的接口,在餐厅的例子中,我们称此方法为orderUp,然而,这里改为execute
这就是命令接口:
public interface Command {
       public void execute();
 
}
 
实现一个打开电灯的命令
 现在,假想实现一个打开电灯的命令,根据厂商提供的类,Light类有2个方法,on() off(),下面是如何将它实现一个命令:
 
public class LightOnCommand implements Command{
      Light light;
       public LightOnCommand(Light light)
      {
             this.light =light;
      }
      
       public void execute()
      {
             light.on();
      }
 
}
 
构造器被传入了某个电灯(比方说,客厅的电灯),一般让这个命令控制,然后记录在实例变量里,一旦调用了execute(),就由这个电灯对象成为接收者。
负责接受请求。
 
使用命令对象 
  假设我们有一个遥控器,他只有一个按钮和对应的插槽,可以控制一个装置:
 
public class SimpleRemoteControl {
      Command slot;
       public SimpleRemoteControl() { }
       public void setCommand(Command command)
      {
             slot=command;
            
      }
      
       public void buttonWasPressed()
      {
             slot.execute();
      }
 
 
}
 
遥控器的简单测试
 下面的代码用来测试上面的简单遥控器:
public class RemoteControlTest {
       public static void main(String[] args)
      {
            SimpleRemoteControl remote= new SimpleRemoteControl();
            Light light= new Light("bathroom" );
            LightOnCommand lightOn= new LightOnCommand(light);
            
            remote.setCommand(lightOn);
            remote.buttonWasPressed();
         
                  
      }
 
}


 
遥控器就是Invoker,会传入一个命令对象,可以用来发出请求。仔细看上面这个图,说的非常好
 
定义命令模式
  命令模式将“请求”封装成对象,以便使用不同的请求,队列或者日志来参数化其他对象,命令模式也支持可撤销的操作
 

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


讨论
mary:体会了命令模式,我们应该怎么完成遥控器设计
sue:就像我们上面做的一样,我们需要提供一个方法,将命令指定到插槽,实际上,我们有7个插槽,每个插槽都具备了“开”“关”按钮,所以我可以用类似方法,把命令指定给遥控器,向这样:
onCommand[0]=onCommand;
offCommand[0]=offCommand;
 
mary:很有道理,但是电灯对象应该排除。遥控器如何分辨客厅或厨房的电灯?
sue:遥控器无法区分这些,遥控器除了在按下按钮时,调用对应的命令对象的execute()之外,它什么都不知道。
 
sue:当我们创建命令并将其加载到遥控器时,我们创建的命令是两个LightCommand,其中一个绑定到客厅电灯对象,另一个绑定到厨房对象。别忘了,命令中封装了请求的接受者。所以,在按下按钮时,根本不需要理会打开哪一个电灯。只有execute被调用,该按钮的对应对象就有动作。
 
将命令指定到插槽
 
 我们计划这样:我们打算将遥控器的每个插槽,对应到一个命令这样我们就让遥控器“变成”调用者“,当按下按钮,相应命令对象的execute()方法就会被调用,接收者(例如:电灯,天花板等)的动作被调用。

 

实现遥控器:

package remote;

public class RemoteControl {
    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;
            
        }
    }
    public void setCommand(int slot,Command onCommand,Command offCommand)
    {
        onCommands[slot]=onCommand;
        offCommands[slot]=offCommand;
    }
    public void onButtonWasPushed(int slot)
    {
        onCommands[slot].execute();
    }
    public void offButtonWasPushed(int slot)
    {
        offCommands[slot].execute();
        
    }
    
    public String toString()
    {
        StringBuffer stringBuff=new StringBuffer();
        stringBuff.append("\n----------Remote Control-----------\n");
        for(int i=0;i<onCommands.length;i++){
            stringBuff.append("[slot"+i+"]"+onCommands[i].getClass().getName()+"  "+offCommands[i].getClass().getName()+"\n");
            
        }
        return stringBuff.toString();
    }
}

覆盖toString(),打印出每个插槽和他对应的命令。

 

实现命令。

 我们前面已经写了LightOnCommand,关闭命令没什么不同。看起来是这样:

public class LightOffCommand implements Command {
    Light light;
 
    public LightOffCommand(Light light) {
        this.light = light;
    }
 
    public void execute() {
        light.off();
    }
}

 

 让我们来提高挑战性,如何为音响(Stereo)编写开与关的命令?好了,很容易,虽然开音响有很多步骤,可以全写在execute(),就可以打开音响了。

public class Stereo {
    String location;

    public Stereo(String location) {
        this.location = location;
    }

    public void on() {
        System.out.println(location + " stereo is on");
    }

    public void off() {
        System.out.println(location + " stereo is off");
    }

    public void setCD() {
        System.out.println(location + " stereo is set for CD input");
    }

    public void setDVD() {
        System.out.println(location + " stereo is set for DVD input");
    }

    public void setRadio() {
        System.out.println(location + " stereo is set for Radio");
    }

    public void setVolume(int volume) {
        // code to set the volume
        // valid range: 1-11 (after all 11 is better than 10, right?)
        System.out.println(location + " Stereo volume set to " + volume);
    }
}

 

public class StereoOnWithCDCommand implements Command {
    Stereo stereo;
 
    public StereoOnWithCDCommand(Stereo stereo) {
        this.stereo = stereo;
    }
 
    public void execute() {
        stereo.on();
        stereo.setCD();
        stereo.setVolume(11);
    }
}

 

其余的厂商类都类似这样。

 逐步测试遥控器

   我们剩下了要做的就是运行测试和准备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");
      
      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);
    
    StereoOnWithCDCommand stereoOnWithCD =
        new StereoOnWithCDCommand(stereo);
  StereoOffCommand  stereoOff =
        new StereoOffCommand(stereo);

     remoteControl.setCommand(0,livingRoomLightOn,livingRoomLightOff);
     remoteControl.setCommand(1,kitchenLightOn,kitchenLightOff);
     remoteControl.setCommand(2,stereoOnWithCD,stereoOff);
     
     System.out.println(remoteControl);
     
     remoteControl.onButtonWasPushed(0);
     remoteControl.offButtonWasPushed(0);
     
     remoteControl.onButtonWasPushed(2);
     remoteControl.offButtonWasPushed(2);
     

    
  }
}

输出:

----------Remote Control-----------
[slot0]remote.LightOnCommand remote.LightOffCommand
[slot1]remote.LightOnCommand remote.LightOffCommand
[slot2]remote.StereoOnWithCDCommand remote.StereoOffCommand
[slot3]remote.NoCommand remote.NoCommand
[slot4]remote.NoCommand remote.NoCommand
[slot5]remote.NoCommand remote.NoCommand
[slot6]remote.NoCommand remote.NoCommand

living Room light is on
living Room light is off

 

注意:

System.out.println(remoteControl);

会调用对象的toString方法,

等一下,NoCommand是什么?在遥控器中,我们不想每次都检查是否某个插槽都加载了命令,比方说,在onButtonWasPushed()方法中,我们可能需要这样的代码:

public void onButtonWasPushed(int slot)

  {

     if(onCommands[slot]!=null){

             onCommands[slot].execute();

  }

}

所以,如何避免上面的代码呢?实现一个不做事情的命令。

public class NoCommand implements Command{

public void execute() { }

}

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

Nocommand对象是一个空对象(null object)的例子。当你不想返回一个有意义的对象时,空对象就很有用。客户也可以将处理null的责任转移给空对象。

 

写文档的时刻终于到了。。。

 

 

我们还忘了实现undo 命令!

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

public interface Command {
public void execute();
public void undo();
}

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

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

public class LightOnCommand implements Command{
    Light light;
    public LightOnCommand(Light light)
    {
        this.light=light;
    }
    
    public void execute()
    {
        light.on();
    }
    public void undo()
    {
        light.off();
    }

}

现在处理LightOffCommand,这里,undo()需要调用on()方法:

public class LightOffCommand implements Command

{
    Light light;
    public LightOffCommand(Light light)
    {
        this.light=light;
    }
    public void execute()
    {
        light.off();
    }
    public void undo()
    {
        light.on();
    }

}

 

事情还没完,我们还要花些力气,让遥控器能够追踪最后被按下的什么按钮。

3.要加上对undo按钮的支持,我们需要加入一个新的实例变量,用来追踪最后的命令。然后,不管何时撤销按钮被按下,我们都可以取出这个命令并调用它的undo()方法。

package remote;

public class RemoteControl {
    Command[] onCommands;
    Command[] offCommands;
    Command undoCommand;
    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;
            
        }
        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=onCommands[slot];
    }
    public void offButtonWasPushed(int slot)
    {
        offCommands[slot].execute();
        undoCommand=offCommands[slot];
    }
    
    public void undoButtonWasPushed()
    {
        undoCommand.undo();
    }
    
    public String toString()
    {
       ....
    }

}

 

在测试程序中,我们调用remoteControl.undoButtonWasPushed()就可以undo了。

remoteControl.onButtonWasPushed(0);
remoteControl.offButtonWasPushed(0);
remoteControl.onButtonWasPushed(2);
remoteControl.undoButtonWasPushed();
remoteControl.offButtonWasPushed(2);

 

使用状态实现撤销

  好了,实现电灯的撤销是有意义的,但也实在太简单了。通常,想要实现undo的功能,需要记录一些状态。让我们试一个有趣的例子,厂商中的吊扇允许有多种转速,当然也允许被关闭。

吊扇的源代码如下:

public class CeilingFan {
    String location = "";
    int level;
    public static final int HIGH = 2;
    public static final int MEDIUM = 1;
    public static final int LOW = 0;
 
    public CeilingFan(String location) {
        this.location = location;
    }
  
    public void high() {
        // turns the ceiling fan on to high
        level = HIGH;
        System.out.println(location + " ceiling fan is on high");
 
    } 

    public void medium() {
        // turns the ceiling fan on to medium
        level = MEDIUM;
        System.out.println(location + " ceiling fan is on medium");
    }

    public void low() {
        // turns the ceiling fan on to low
        level = LOW;
        System.out.println(location + " ceiling fan is on low");
    }
 
    public void off() {
        // turns the ceiling fan off
        level = 0;
        System.out.println(location + " ceiling fan is off");
    }
 
    public int getSpeed() {
        return level;
    }
}

 

加入撤销到吊扇的命令类

  我们想要实现吊扇的undo,需要追踪吊扇的最后设置速度。如果undo()被调用了,就要恢复为之前的速度。下面是

 

public class CeilingFanHighCommand implements Command {
    CeilingFan ceilingFan;
    int prevSpeed;
  
    public CeilingFanHighCommand(CeilingFan ceilingFan) {
        this.ceilingFan = ceilingFan;
    }
 
    public void execute() {
        prevSpeed = ceilingFan.getSpeed();
        ceilingFan.high();
    }
 
    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命令类似上面的。

 测试代码简写如下:

 CeilingFan ceilingFan = new CeilingFan("Living Room");
CeilingFanHighCommand ceilingFanHigh = 
        new CeilingFanHighCommand(ceilingFan);
CeilingFanLowCommand ceilingFanLow = 
        new CeilingFanLowCommand(ceilingFan);
CeilingFanOffCommand ceilingFanOff = 
    new CeilingFanOffCommand(ceilingFan);
 
 remoteControl.setCommand(3, ceilingFanHigh, ceilingFanOff);
        remoteControl.setCommand(4, ceilingFanLow, ceilingFanOff);
 remoteControl.onButtonWasPushed(3);
     remoteControl.onButtonWasPushed(4);
     remoteControl.undoButtonWasPushed();

 

每个遥控器都需具备“party模式”!

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

一般的想法是,制造一种新的命令,用来执行其他一堆命令,而不只是执行一个命令!

public class MacroCommand implements Command{
    Command[] commands;
    public MacroCommand(Command[] commands)
    {
        this.commands=commands;
        
    }
    
    public void execute() {
         for(int i=0;i<commands.length;i++){
             commands[i].execute();
         }
        
    }
 
     /**
     * NOTE:  these commands have to be done backwards to ensure proper undo functionality
     */
    public void undo() {
        for (int i = commands.length -1; i >= 0; i--) {
            commands[i].undo();
        }
    }
    
}

使用宏命令

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

 

Light light = new Light("Living Room");
        TV tv = new TV("Living Room");
        Stereo stereo = new Stereo("Living Room");
        Hottub hottub = new Hottub();
 
        LightOnCommand lightOn = new LightOnCommand(light);
        StereoOnCommand stereoOn = new StereoOnCommand(stereo);
        TVOnCommand tvOn = new TVOnCommand(tv);
        HottubOnCommand hottubOn = new HottubOnCommand(hottub);
        LightOffCommand lightOff = new LightOffCommand(light);
        StereoOffCommand stereoOff = new StereoOffCommand(stereo);
        TVOffCommand tvOff = new TVOffCommand(tv);
        HottubOffCommand hottubOff = new HottubOffCommand(hottub);

 

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

Command[] partyOn = { lightOn, stereoOn, tvOn, hottubOn};
        Command[] partyOff = { lightOff, stereoOff, tvOff, hottubOff};
  
        MacroCommand partyOnMacro = new MacroCommand(partyOn);
        MacroCommand partyOffMacro = new MacroCommand(partyOff);

3.然后将宏命令指定给我们希望的按钮

 remoteControl.setCommand(0, partyOnMacro, partyOffMacro);

 

 

问题:

 我如何能够实现多层次的undo操作?

   其实很容易,不要只是记录最后一个执行的命令,而使用一个堆栈记录来操作过程的每一个命令。然后,不管什么时候按下了undo按钮,都可以从堆栈中取出最上层的命令,然后调用undo方法。

 

 命令模式的更多用途:队列请求和日志请求

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

 

 

 

总结:在被解耦的两者之间是通过命令对象进行沟通的。命令对象封装了接收者和一个或一组动作

调用者通过调用命令对象的execute()发出请求,这会使得接收者的动作被调用。

命令可以支持撤销,做法是实现一个undo()方法来回到execute()被执行前的状态。

 

命令可以用来实现日志和事务系统。