设计模式的思考-命令模式

381 阅读13分钟

本系列文章是自己在学习设计模式时的思考过程的记录,对内容的正确性和严谨程度不做保证。

1. 模式的定义

命令模式作为一种行为型设计模式,其较为标准的定义是:

将请求封装成对象,从而让你使用不同的请求参数化,来实现队列或者日志功能。命令模式也可以支持撤销操作。

其UML类图为:

Untitled.png

为了便于后面的讨论,我们可以从《Head First设计模式》书中搬出其使用场景:

场景

需要为某家电自动化公司设计一款遥控器来控制一堆来自不同厂商的电器,此要遥控器具有七个可编程的插槽,每个插槽都对应一组开关按钮,并且具备一个整体的撤销按钮。

实现代码

  • 抽象命令

    public interface Command
    {
        //执行命令的方法
        public void execute();
        //撤销命令方法
        public void undo();
    }
    
  • 具体电器命令

    public class LightOnCommand : Command
    {
        Light light;
     
        public LightOnCommand(Light light)
        {
            this.light = light;
        }
        public void execute()
        {
            light.on();
        }
        public void undo() {
            light.off();
        }
    }
     
     
    class LightOffCommand : Command
    {
        Light light;
     
        public LightOffCommand(Light light)
        {
            this.light = light;
        }
        public void execute()
        {
            light.off();
        }
     
        public void undo()
        {
            light.on();
        }
    }
    
  • Invoker类

    public class RemoteControl
    {
        Command[] onCommands;
        Command[] offCommands;
        Command undoCommand;
        public RemoteControl()
        {
            onCommands = new Command[5];
            offCommands = new Command[5];
            Command noCommand = new NoCommand();
            for (int i = 0; i < 5; i++)
            {
                onCommands[i] = noCommand;
                offCommands[i] = noCommand;
            }
        }
        public void setCommand(int slot,Command commandOn, Command commandOff)
        {
            onCommands[slot] = commandOn;
            offCommands[slot] = commandOff;
        }
     
        //按下开关
        public void OnButtonWasPressed(int slot)
        {
            onCommands[slot].execute();
            undoCommand = onCommands[slot];
        }
        //关闭开关
        public void OffButtonWasPressed(int slot)
        {
            offCommands[slot].execute();
            undoCommand = offCommands[slot];
        }
     
        public void UndoButtonWasPressed() {
            undoCommand.undo();
        }
        //打印出数组命令对象
        public override string ToString() {
            var sb = new StringBuilder("\n------------Remote Control-----------\n");
            for (int i = 0; i < onCommands.Length; i++)
            {
                sb.Append($"[slot{i}] {onCommands[i].GetType()}\t{offCommands[i].GetType()} \n");
            }
            return sb.ToString();
        }
     
    }
    

2. 过程的思考

从场景到模式引入

从上面列出的场景来看,我们需要提供一个遥控器类,来管理目前已有的厂商和未来可能加入的其他厂商的产品的开关功能。完成这个需求,我们可以按照以下的步骤进行分解:

  1. 需要完成的功能

    需要完成的功能,我们前面也说的很清楚,当然我们也需要看一下摆在桌子上的两个角色:遥控器类、多个厂商电器类(提供了各种开关方法)。我们需要为遥控器提供一种简单且易扩展的集成方式,能够兼容现有和未来可能变化的电器,而提到集成方式如何简单,最好是遥控器不需要管实际电器的开关操作该如何发生,它最好只需要告诉电器对象:打开、关闭,就能简单的控制它们。

  2. 如何应对变化

    上一步我们提到要为遥控器提供一个简单且易扩展的电器集成方式,这一步我们思考该如何完成这样的功能。先说简单,按照我们的设想,如果只需要告诉电器对象打开或者关闭,电器就能自己完成操作,那么传入到控制器的电器类,应该都拥有相同的抽象,这样才能让操作变得简单,而实际上这些电器类的开关操作方法都各有各的方式,并没有统一,这里我们可能会想到,提供一个开和关的协议,让这些电器类都自己实现,这样就完成了简单的抽象。但是我们知道,对厂商类直接进行修改并不是一个好的方式,因为考虑到厂商类的可移植性和未来更新,我们不应该直接对这些类进行修改而满足我们的抽象接口,更何况厂商不一定提供了源码,而只提供了一个二进制库和对外的操作接口。

    其实说到这里,我们可以想到另一种模式,也就是适配器模式,通过适配器的方式,可以在不修改类自身的情况下满足新的接口,只需要创建一个适配器类来通过委托接受者实现我们的抽象。所以我们开始为我们的电器厂商们分别创建不同的适配器类,当然我们的适配器实际上做的事,要比一般的适配器要更累,因为其可能不仅仅是简单的数据转换,而是可能要稍微接近于一个外观模式要做的对外透明性。

    再说扩展,在说可扩展性的时候,实际上我们是想说未来的变化对我们造成的影响是否可以控制在我们的弹性设计中。我们应该要对未来发生的变化进行评估和设想,那么是否有一种设计能做到满足任何的变化么?我觉得没有谁能敢保证做到如此完美,而且对这种假想为目的的设计,势必会给自己找不自在,甚至在工作上有这种想法还会造成项目的延期。对未来发生的变化的设想,需要保持在一定的领域内,还需要对这个方向有一定的经验对未来的走向有相对符合思维的演进预估,过多不切实际的设想我认为是浪费时间和经历,这个不切实际,第一是不符合前面所说的拥有这个方向的一定经验做出的符合思维的演进预估,第二是不符合目前公司的实际情况。当我们预估到合理的变化趋向时,要做的就是对变化的不变的部分和进行隔离,使其变化过程中,我们要做的事足够的简单和少,如果要给其定一个比较通用的原则的话,那便是开闭原则,应对变化时,我们进来保持不修改以前的代码,而是增加新的代码。

    那么我们再看前面在提出简单的应对设计时,实际上有符合扩展的要求么?我认为是符合的,因为其抽象了操作方式。别忘了我们还需要支持撤销功能,那么目前的设计能完成撤销功能么?由于我们的抽象接口同时提供了打开和关闭方法,如果再增加一个撤销方法,我们需要对上一步是“打开”还是“关闭”进行记录,最后在撤销方法中进行判断。这些记录和判断,我们甚至要在每一个“适配器”类里都写一遍,着实不符合良好的面向对象设计,那应该怎么办?目前我想到两种办法,第一种,我们对抽象接口进行修改,改成一个抽象类,将这些重复的部分都封装到抽象类中;另一种方式是,我们对开和关这两个操作分别封装,这样每个“适配器”类中就只存在一种操作,所以其撤销的上一步也只存在一种。

    在考虑上面这个问题该使用哪一种方式时,我们再同时考虑另一个问题,遥控器未来是否会增加其他设置功能,比如电风扇的风速,而不一定所有电器都拥有调节功能,比如一个路由器。结合这个问题,我们在回到上面提出问题的两种解决方案,其中第一种解决方案对目前这个问题没有任何帮助,实际上也带来的另一个问题:我们的接口不符合单一职责原则,由于不符合这个原则,我们的接口并不适用于所有的厂商电器类,所以我们考虑第二种解决方案,对开和关分别封装,怎么封装呢?难道我们设计出两个接口?一个开和一个关?那肯定不行,这样实际上我们连基本的简单性都不符合了,这样会对遥控器类扩展性造成影响,因为遥控器未来就必须始终支持开和关的功能,且未来多一个设置功能,就得加一个对外方法,而去掉一个设置功能,又得删除已有的方法,这样转来转去已经将问题退回到了原地。既然接口中只有一个方法,我们可以将接口中的方法抽象为仅仅一个“执行”操作,而具体的操作方式,可以由类名进行描述,这样一来我们也回到了设计的正轨,继续进行吧。

    到了这一步,我们的接口就只剩下执行操作,而遥控器也根本不需要知道到底是什么操作,因为遥控器提供的设置方法,只知道设置进来的类支持“执行”。在这个阶段,我认为已经达到了一定的满足未来变化的弹性设计,至少对于遥控器来说。

  3. 需要解耦的部分

    前面的部分我们是从针对变化的视角来看待这个场景问题,现在我们将从解耦部分来进行描述,其中可能会经常提出前面的设计,但是其看待的视角是不同的。

    说到解耦,针对这些场景,我们思考一下要解耦的是哪些角色的哪些交互关系呢?答案是遥控器类和那些电器类。如果按照一个简单的设计方式,遥控器类的代码里,要对不同的电器进行不同的调用,这样会产生直接的依赖关系,我们是对不同的电器的实现进行编程,不符合依赖倒置原则,而此原则也给了我们一个解决方案,应针对其抽象编程,引入前面部分的”适配器“办法,完美符合了依赖倒置原则,对这部分依赖进行了拆分。

    我们往前面的UML类图部分看一下,似乎命令模式还引入了另一个耦合关系,客户场景对Receiver类产生了关联?实际上这部分是对前面的”适配器“类的设计依赖(从下面开始,我们将称呼这个所谓的”适配器“类为命令类),我们将控制电器的操作封装进了命令类里,可以有两个选择,一是将电器类直接耦合进命令类里,此时命令类和电器类产生了耦合,我们这部分要讲述的也是耦合问题,那这里的耦合难道不应该被修正吗?我们在代码设计时,应该考虑的是需要实际需要解耦部分的耦合,这部分的耦合之所以需要被解决,是因为其无法应对未来的变化,而此处我们遇到的命令类对电器类产生的耦合,我们可以认为是前面提到过的”需要被隔离的变化“部分,它的未来变化不是直接修改,而是通过创建新命令类的方式进行扩展,所以这个被封装了变化的角色,是整个设计模型中必须要针对实现编程的,我们不能被迷惑了。刚刚讲述了第一个选择,而第二个选择是将电器类通过依赖注入的方式注入到命令类中,使命令类可以动态的封装被控制的电器,这个设计实际上是针对同类型的电器实例出现多个,而每个所代表的意义或者说用处不同时准备的。这种选择的设计,在某些情况下,还可以再引入一层抽象,对注入的类抽象化。而此时讨论的选择就伴随着客户场景需要主动将Receiver构造出来,产生了耦合,实际上这部分耦合是可以被我们忽略的,因为就像我们直接依赖具体命令,这是”不可避免“的,而在遇到一些复杂系统的设计上,我们也可以将这部分耦合通过工厂的方式进行拆分。

    看到这里,我们发现在解决耦合问题时,往往会出现一些不可避免的耦合,这个情况下需要我们明确对这个设计上真正需要解耦的部分,而不可避免的外围部分,应该随着场景的复杂性提高时,逐步的进行的确需要的解耦。

和其他模式的对比

我们从整个模式的角色和交互来看,其实和其他模式会有些像,比如策略模式,如果只看UML类图,甚至几乎一模一样,那么实际情况呢?

从原理来看,和很多模式一样,命令模式也是通过委托的方式,将请求者的动作动态转换为响应者的动作,但是其主要是设计的出发点不同,面向的场景也不同,这里拿策略模式进行举例,我们知道策略模式是将一组可以互相替换的算法行为进行封装,在使用时可以进行动态的替换从而完成不同的功能。这样的策略模式,其主要面向的是单个目的的多种不同实现方式,其目的一般都是相同的,只是中间实现这个目的的行为过程不同;而命令模式是面向不同的目的,对于每个目的提供不同的行为过程。另一个不同之处在于其相对整个请求交互的完整性:在运行一个请求时,命令类内封装的行为基本等于全部的行为(宏命令实际上也是,请不要被迷惑),而策略不一定是一个请求的全部,而作为一个部分,比如在一个消息发送的策略设计中,消息的发送类型和传输协议被设计成可替换的策略,这样发送类型策略和传输协议策略都只是作为整个请求过程中的部分来完成工作。

实际上现有的设计模式集中,有很多表面上十分相似的设计模式,但是其出发点却十分迥异,由于出发点的不同,其在实际应用中进行的扩展方式所表现出的行为和功能会将其衬托的愈来愈明显,直至让人恍然大悟。

命令模式的其他闲谈

前面的场景例子,基本上是将UML类图所描述的角色进行对号入座,能够阐述的特点也比较单调。在目前的实际应用中,命令模式有着一些非常不错的扩展,比如任务队列:队列作为Invoker角色,任务作为命令角色,每个任务会以一些特殊的机制保存在队列中,以任何可能的方式在任何可能的时间执行,甚至任务之间还支持设置依赖关系(比如苹果Foundation框架中的NSOperation),个人也认为这种设计在命令模式中十分具有代表性。

3. 结语

以上内容是我在学习命令模式时产生的一些思考或者说臆想,由于是第一篇,所以写的也相对比较啰嗦,其目的主要还是作为个人的记录,如果正好你发现了我思路中的错误,希望能指出最好能和我一起交流,万分感谢!

原文地址:www.notion.so/nakahira/e8…

4. 参考资料

《Head First设计模式》