命令模式

思考并回答以下问题:

  • 最简单的抽象命令类万年不变是什么意思?
  • 命令模式为什么是行为型模式?是如何将请求发送者与请求接收者解耦的?
  • 什么是命令对象?为什么实现了间接引用?
  • 是怎么做到可以在不修改现有系统源代码的情况下让相同的发送者对应不同的接收者的?
  • 命令模式的核心在于引入了抽象命令类和具体命令类。怎么理解?
  • 可以在具体命令类里直接new请求接收者,也可以在客户端注入。怎么理解?
  • “用不同的请求对客户进行参数化”,客户端只需要将具体命令对象作为参数注入请求发送者。怎么理解?
  • 将发送者的请求封装在命令对象中,再通过命令对象来调用接收者的方法。怎么理解?
  • 一个调用者并不需要在设计时确定其接收者,因此只与抽象命令类之间存在关联关系。怎么理解?
  • 在具体命令类里写代码时直接new Receiver了。怎么理解?

本章导学

命令模式是常用的行为型模式之一,它将请求发送者与请求接收者解耦,请求发送者通过命令对象来间接引用接收者,使得系统具有更好的灵活性,可以在不修改现有系统源代码的情况下让相同的发送者对应不同的接收者。

本章将学习命令模式的定义与结构,结合实例学习如何实现命令模式,并理解命令队列、请求日志、撤销操作和宏命令的实现原理。

本章知识点

  • 命令模式的定义
  • 命令模式的结构
  • 命令模式的实现
  • 命令模式的应用
  • 实现命令队列
  • 记录请求日志
  • 实现撤销操作
  • 宏命令
  • 命令模式的优缺点
  • 命令模式的适用环境

命令模式概述

在现实生活中,人们通过使用开关来控制一些电器的打开和关闭,例如电灯或者排气扇。在购买开关时,购买者可能并不知道它将来到底用于控制什么电器,也就是说,开关与电灯、排气扇并无直接关系,一个开关在安装之后可能用来控制电灯,也可能用来控制排气扇或者其他电器设备。开关与电器之间通过电线建立连接,如果开关打开,则电线通电,电器工作;反之,开关关闭,电线断电,电器停止工作。相同的开关可以通过不同的电线来控制不同的电器,如图1所示。

图1 开关与电灯、排气扇示意图

在图1中,可以将开关理解成一个请求的发送者,用户通过它来发送一个“开灯”请求,而电灯是“开灯”请求的最终接收者和处理者,开关和电灯之间并不存在直接耦合关系,它们通过电线连接在一起,使用不同的电线可以连接不同的请求接收者,只需更换一根电线,相同的发送者(开关)即可对应不同的接收者(电器)。

在软件开发中也存在很多与开关和电器类似的请求发送者和接收者对象,例如一个按钮,它可能是一个“关闭窗口”请求的发送者,而按钮单击事件处理类则是该请求的接收者。为了降低系统的耦合度,将请求的发送者和接收者解耦,可以使用一种被称为命令模式的设计模式来设计系统。在命令模式中,发送者与接收者之间引入了新的命令对象(类似图1中的电线),将发送者的请求封装在命令对象中,再通过命令对象来调用接收者的方法。

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

命令模式的定义如下:

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

命令模式是一种对象行为型模式,其别名为动作(Action)模式或事务(Transaction)模式。命令模式的定义比较复杂,提到了很多术语,例如“用不同的请求对客户进行参数化”、“对请求排队”、“记录请求日志”、“支持可撤销操作”等。

命令模式的结构与实现

命令模式的结构

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

图2 命令模式结构图

由图2可知,命令模式包含以下4个角色。

(1)Command(抽象命令类):抽象命令类一般是一个抽象类或接口,在其中声明了用于执行请求的Execute()等方法,通过这些方法可以调用请求接收者的相关操作。

(2)ConcreteCommand(具体命令类):具体命令类是抽象命令类的子类,实现了在抽象命令类中声明的方法,它对应具体的接收者对象,将接收者对象的动作绑定其中。具体命令类在实现Execute()方法时,将调用接收者对象的相关操作。

(3)Invoker(调用者):调用者即请求发送者,它通过命令对象来执行请求。一个调用者并不需要在设计时确定其接收者,因此只与抽象命令类之间存在关联关系。在程序运行时可以将一个具体命令对象注入其中,再调用具体命令对象的Execute()方法,从而实现间接调用请求接收者的相关操作。

(4)Receiver(接收者):接收者执行与请求相关的操作,具体实现对请求的业务处理。

命令模式的实现

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

命令模式的关键在于引入了抽象命令类,请求发送者针对抽象命令类编程,只有实现了抽象命令类的具体命令才与请求接收者相关联。在最简单的抽象命令类中只包含了一个抽象的Execute()方法,每个具体命令类将一个Receiver类型的对象作为一个实例变量进行存储,从而具体指定一个请求的接收者,不同的具体命令类提供了Execute()方法的不同实现,并调用不同接收者的请求处理方法。

典型的抽象命令类代码如下:

1
2
3
4
abstract class Command
{
public abstract void Execute();
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Invoker
{
private Command command;

// 构造注入
public Invoker(Command command)
{
this.command = command;
}

public Command command
{
get{ return command; }
// 设值注入
set{ command = value; }
}

// 业务方法,用于调用命令类的方法
public void Call()
{
command.Execute();
}
}

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

1
2
3
4
5
6
7
8
9
10
11
class ConcreteCommand : Command
{
// 维持一个对请求接收者对象的引用
private Receiver receiver;

public override void Execute()
{
// 调用请求接收者的业务处理方法Action()
receiver.Action();
}
}

请求接收者Receiver具体实现对请求的业务处理,它拥有Action()方法,用于执行与请求相关的操作。其典型代码如下:

1
2
3
4
5
6
7
class Receiver
{
public void Action()
{
// 具体操作
}
}

命令模式的应用实例

下面通过一个应用实例来进一步学习和理解命令模式。

1.实例说明

为了用户使用方便,某系统提供了一系列功能键,用户可以自定义功能键的功能,例如功能键FunctionButton可以用于退出系统(由SystemExitClass类来实现),也可以用于显示帮助文档(由DisplayHelpClass类来实现)。

用户可以通过修改配置文件来改变功能键的用途,现使用命令模式来设计该系统,使得功能键类与功能类之间解耦,可为同一个功能键设置不同的功能。

2.实例类图

通过分析,本实例的结构如图3所示。

图3 功能键设置结构图

在图3中,FunctionButton充当请求调用者,SystemExitClass和DisplayHelpClass充当请求接收者,Command是抽象命令类,ExitCommand和HelpCommand充当具体命令类。

3.实例代码

(1)FunctionButton:功能键,充当请求调用者(请求发送者)。

FunctionButton.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;

namespace CommandSample
{
class FunctionButton
{
private Command command; // 维持一个抽象命令对象的引用

// 为功能键注入命令
public Command Command
{
get{ return command; }
set{ command = value; }
}

// 发送请求的方法
public void Click()
{
Console.WriteLine("单击功能键!");
command.Execute();
}
}
}

(2)Command:抽象命令类

Command.cs

1
2
3
4
5
6
7
namespace CommandSample
{
abstract class Command
{
public abstract void Execute();
}
}

(3)ExitCommand:退出命令类,充当具体命令类

ExitCommand.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace CommandSample
{
class ExitCommand : Command
{
private SystemExitClass seObj; // 维持对请求接收者的引用

public ExitCommand()
{
seObj = new SystemExitClass();
}

// 命令执行方法,将调用请求接收者的业务方法
public override void Execute()
{
seObj.Exit();
}
}
}

(4)HelpCommand:帮助命令类,充当具体命令类。

HelpCommand.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace CommandSample
{
class HelpCommand : Command
{
private DisplayHelpClass hcObj; // 维持对请求接收者的引用

public HelpCommand()
{
hcObj = new DisplayHelpClass();
}

// 命令执行方法,将调用请求接收者的业务方法
public override void Execute()
{
hcObj.Display();
}
}
}

(5)SystemExitClass:退出系统模拟实现类,充当请求接收者。

SystemExitClass.cs

1
2
3
4
5
6
7
8
9
10
11
12
using System;

namespace CommandSample
{
class SystemExitClass
{
public void Exit()
{
Console.WriteLine("退出系统");
}
}
}

(6)DisplayHelpClass:显示帮助文档模拟实现类,充当请求接收者。

DisplayHelpClass.cs

1
2
3
4
5
6
7
8
9
10
11
12
using System;

namespace CommandSample
{
class DisplayHelpClass
{
public void Display()
{
Console.WriteLine("显示帮助文档!");
}
}
}

(7)配置文件App.config:在配置文件中存储了具体命令类的类名。

App.config

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="command" value="CommandSample.ExitCommand" />
</appSettings>
</configuration>

(8)Program:客户端测试类。

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System;
using System.Configuration;
using System.Reflection;

namespace CommandSample
{
class Program
{
static void Main(string[] args)
{
FunctionButton fb = new FunctionButton();

Command command; // 定义命令对象
// 读取配置文件
string commandStr = ConfigurationManager.AppSettings["command"];
// 反射生成对象
command = (Command)Assembly.Load("CommandSample").CreateInstance(commandStr);

// 将命令对象注入功能键
fb.Command = command;
// 调用功能键的业务方法
fb.Click();

Console.Read();
}
}
}

4.结果及分析

编译并运行程序,输出结果如下:

1
2
单击功能键!
退出系统!

如果需要更换具体命令类,无需修改源代码,只需修改配置文件,例如将退出命令改为帮助命令,只需将存储在配置文件中的具体命令类类名ExitCommand改为HelpCommand。其代码如下:

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="command" value="CommandSample.HelpCommand" />
</appSettings>
</configuration>

重新运行客户端程序,输出结果如下:

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

如果在系统中增加了新的功能,功能键需要与新功能对应,只需要对应增加一个新的具体命令类,在新的具体命令类中调用新功能类的业务方法,然后将该具体命令类的对象通过配置文件注入功能键即可,原有代码无须修改,符合开闭原则。

在命令模式中,每一个具体命令类对应一个请求的处理者(接收者),通过向请求发送者注入不同的具体命令对象可以使相同的发送者对应不同的接收者,从而实现“将一个请求封装为一个对象,用不同的请求对客户进行参数化”,客户端只需要将具体命令对象作为参数注入请求发送者,无须直接操作请求的接收者。

实现命令队列

当一个请求发送者发送一个请求时,有不止一个请求接收者产生响应,这些请求接收者将逐个执行业务方法,完成对请求的处理。此时,可以通过命令队列来实现。

命令队列的实现方法有多种形式,其中最常用、灵活性最好的一种方式是增加一个CommandQueue类,由该类负责存储多个命令对象,而不同的命令对象可以对应不同的请求接收者。CommandQueue类的典型代码如下:

CommandQueue.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System.Collections.Generic;

namespace CommandSample
{
class CommandQueue
{
// 定义一个List来存储命令队列
private List<Command> commands = new List<Command>();

public void AddCommand(Command command)
{
commands.Add(command);
}

public void RemoveCommand(Command command)
{
commands.Remove(command);
}

// 循环调用每一个命令对象的Execute()方法
public void Execute()
{
foreach(object command in commands)
{
((Command)command).Execute();
}
}
}
}

在增加了命令队列类CommandQueue以后,请求发送者Invoker将针对CommandQueue编程。其代码修改如下:

Invoker.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
namespace CommandSample
{
class Invoker
{
// 维持一个CommandQueue对象的引用
private CommandQueue commandQueue;

// 构造注入
public Invoker(CommandQueue commandQueue)
{
this.commandQueue = commandQueue;
}

// 设值注入
public void SetCommandQueue(CommandQueue commandQueue)
{
this.commandQueue = commandQueue;
}

// 调用CommandQueue类的Execute()方法
public void Call()
{
commandQueue.Execute();
}
}
}

命令队列与人们常说的“批处理”有点类似。批处理,顾名思义,可以对一组命令对象进行批量处理,当一个发送者发送请求后,将有一系列接收者对请求作出响应。命令队列可以用于设计批处理应用程序,如果请求接收者的接收次序没有严格的先后次序,还可以使用多线程技术并发调用命令对象的Execute()方法,从而提高程序的执行效率。

记录请求日志

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

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

在实现请求日志时,可以将发送请求的命令对象通过序列化写到日志文件中,此时命令类必须使用属性[Serializable]标记为可序列化。

实现撤销操作

在命令模式中,用户可以通过对命令类进行修改使得系统支持撤销(Undo)操作和恢复(Redo)操作,下面通过一个简单实例来学习如何在命令模式中实现撤销操作。

设计一个简易计算器,该计算器可以实现简单的数学运算,还可以对运算实施撤销操作。

使用命令模式设计可得到图4所示的结构图,其中,计算器界面类CalculatorForm充当请求发送者,实现了数据求和功能的加法类Adder充当请求接收者,界面类可间接调用加法类中的Add()方法实现加法运算,并且提供了可撤销加法运算的Undo()方法。

图4 简易计算器结构图

在实例中,加法类Adder充当请求接收者,其代码如下:

Adder.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace CommandUndoSample
{
class Adder
{
// 定义初始值为0
private int num = 0;

// 加法操作,每次将传入的值与num做加法运算,再将结果返回
public int Add(int value)
{
num += value;
return num;
}
}
}

AbstractCommand充当抽象命令类,声明了Execute()方法和撤销方法Undo()。其代码如下:

AbstractCommand.cs

1
2
3
4
5
6
7
8
namespace CommandUndoSample
{
abstract class AbstractCommand
{
public abstract int Execute(int value);
public abstract int Undo();
}
}

AddCommand充当具体命令类,实现了在抽象命令类AbstractCommand中声明的Execute()方法和撤销方法Undo()。其代码如下:

AddCommand.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace CommandUndoSample
{
class AddCommand : AbstractCommand
{
private Adder adder = new Adder();
private int value;

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

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

CalculatorForm充当请求发送者,它引用一个抽象命令AbstractCommand类型的对象command,通过该command对象间接调用请求接收者Adder类的业务处理方法。其代码如下:

CalculatorForm.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System;

namespace CommandUndoSample
{
class CalculatorForm
{
private AbstractCommand command;

public AbstractCommand Command
{
get{ return command; }
set{ command = value; }
}

// 调用命令对象的Execute()方法执行运算
public void Compute(int value)
{
int i = Command.Execute(value);
Console.WriteLine("执行运算,运算结果为:" + i);
}

// 调用命令对象的Undo()方法执行撤销
public void Undo()
{
int i = Command.Undo();
Console.WriteLine("执行撤销,运算结果为:" + i);
}
}
}

在客户端测试类Program中定义了抽象命令类型的命令对象command,还创建了请求发送者对象form,通过调用form对象的Compute()方法实现加法运算,还可以调用Undo()方法撤销最后一次加法运算。其代码如下。

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;

namespace CommandUndoSample
{
public class Program
{
static void Main(string[] args)
{
CalculatorForm form = new CalculatorForm();
AbstractCommand command;
command = new AddCommand();
form.Command = command;

form.Compute(10);
form.Compute(5);
form.Compute(10);
form.Undo(); // 撤销

Console.Read();
}
}
}

编译并运行程序,输出结果如下:

1
2
3
4
执行运算,运算结果为:10
执行运算,运算结果为:15
执行运算,运算结果为:25
执行撤销,运算结果为:15

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

宏命令

宏命令(Macro Command)又称为组合命令(Composite Command),它是组合模式和命令模式联用的产物。宏命令是一个具体命令类,它拥有一个集合,在该集合中包含了对其他命令对象的引用。通常,宏命令不直接与请求接收者交互,而是通过它的成员来调用接收者的方法。当调用宏命令的Execute()方法时,将递归调用它所包含的每个成员命令的Execute()方法。一个宏命令的成员可以是简单命令,还可以继续是宏命令。执行一个宏命令将触发多个具体命令的执行,从而实现对命令的批处理。宏命令结构如图5所示。

图5 宏命令结构图

命令模式的优缺点与适用环境

命令模式是一种使用频率非常高的设计模式,它可以将请求发送者与接收者解耦,请求发送者通过命令对象来间接引用请求接收者,使得系统具有更好的灵活性与可扩展性。在基于GUI的软件开发,无论是计算机桌面应用还是手机移动应用中,命令模式都得到了广泛的应用。

命令模式的优点

命令模式的主要优点如下:

  • (1)命令模式降低了系统的藕合度。由于请求者与接收者之间不存在直接引用,因此请求者与接收者之间实现了完全解耦,相同的请求者可以对应不同的接收者,同样,相同的接收者也可以供不同的请求者使用,两者之间具有良好的独立性。
  • (2)通过使用命令模式,新的命令可以很容易地加入到系统中。由于增加新的具体命令类不会影响其他类,所以增加新的具体命令类很容易,无须修改原有系统源代码,甚至客户类代码,满足开闭原则的要求。
  • (3)使用命令模式可以比较容易地设计一个命令队列或宏命令(组合命令)。
  • (4)命令模式为请求的撤销(Undo)和恢复(Redo)操作提供了一种设计和实现方案。

命令模式的缺点

命令模式的主要缺点如下:

使用命令模式可能会导致某些系统有过多的具体命令类。因为针对每一个对请求接收者的调用操作都需要设计一个具体命令类,所以在某些系统中可能需要提供大量的具体命令类,,这将影响命令模式的使用。

命令模式的适用环境

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

  • (1)系统需要将请求调用者和请求接收者解耦,使得调用者和接收者不直接交互。请求调用者无须知道接收者的存在,也无须知道接收者是谁,接收者也无须关心何时被调用。
  • (2)系统需要在不同的时间指定请求、将请求排队和执行请求。一个命令对象和请求的初始调用者可以有不同的生命期。换而言之,最初的请求发出者可能已经不在了,但命令对象本身仍然是活动的,可以通过该命令对象去调用请求接收者,而无须关心请求调用者的存在性,可以通过请求日志文件等机制来具体实现。
  • (3)系统需要支持命令的撤销(Undo)操作和恢复(Redo)操作。
  • (4)系统需要将一组操作组合在一起形成宏命令。

本章小结

(1)在命令模式中,将一个请求封装为一个对象,从而让你可以用不同的请求对客户进行参数化,将请求排队或者记录请求日志以及支持可撤销的操作。命令模式是一种对象行为型模式。

(2)命令模式包含抽象命令类、具体命令类、调用者和接收者4个角色。其中,抽象命令类声明了用于执型请求的Execute()等方法,通过这些方法可以调用请求接收者的相关操作;具体命令类是抽象命令类的子类,实现了在抽象命令类中声明的方法,在实现Execute()方法时,将调用接收者对象的相关操作;调用者即请求发送者,它通过命令对象来执行请求;接收者执行与请求相关的操作,具体实现对请求的业务处理。

(3)命令模式的主要优点包括降低了系统的耦合度,增加新的命令很容易,可以比较容易地设计一个命令队列或宏命令,还为请求的撤销和恢复操作提供了一种设计和实现方案。其主要缺点是使用命令模式可能会导致某些系统有过多的具体命令类。

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

(5)如果一个请求发送者发送一个请求后,有不止一个请求接收者产生响应,此时,可以使用命令队列来存储多个命令对象,每个命令对象对应一个请求接收者。

(6)用户可以将命令对象通过序列化写到日志文件中,实现对请求日志的存储。

(7)在命令模式中,用户可以通过对命令类进行修改使得系统支持撤销操作和恢复操作。

(8)宏命令又称为组合命令,它是组合模式和命令模式联用的产物。宏命令是一个具体命令类,它拥有一个集合,在该集合中包含了对其他命令对象的引用。

0%