命令模式(command)简单来说就是将一系列的请求命令封装成对象,而不是直接调用真正执行者的方法,真正的执行由这个封装好的对象来执行,这样比较好扩展。比如我们在应用里面,经常会用到复制粘贴操作,这个操作可以由主菜单下面的按钮发出,也可以由快捷工具栏的按钮发出,也可以由快捷键Ctrl+C、Ctrl+V产生,这些不同的请求者发出的改变行为是一样的,如果各自都直接进行操作,就会使得代码逻辑重复,并且在执行一些撤销Undo或者重做Redo操作时就比较难以实现。
另外,在一些应用场景下,我们需要记录修改之前的值,以便于事后进行审计,或者回滚到之前的值等等。这种普通的直接对对像进行修改或者执行某种逻辑的方式,就无法扩展我们自定义的逻辑,比如日志、审计等等。
场景
假设我们需要对一个允许透支的银行账户进行建模,这个对象包含了两个方法,存款Deposit和取款Withdraw操作。
public class BankAccount
{
private int balance;
private int overdraftLimit = -500;
public void Deposit(int amount)
{
balance += amount;
Console.Write($"Deposit ${amount},balance now is {balance}");
}
public void Withdraw(int amount)
{
if (balance - amount > overdraftLimit)
{
balance -= amount;
Console.Write($"Withdraw ${amount},balance now is {balance}");
}
}
public override string ToString()
{
return $"balance:{balance}";
}
}
现在我们可以直接初始化BankAccount对象然后调用Deposit和Withdraw方法,但是如果考虑到要做审计,我们要让每一笔存款、取款都能记录下来。根据职责分离原理,我们不能将这些额外的逻辑都写道BankAccount这个对象里。
命令模式的实现
命令模式实现起来很简单,首先我们定义一个命令接口,表示执行某种操作。
public interface ICommand
{
void Call();
}
然后定义一个银行账户类实现这一接口,来包装银行账户取款、存款的操作。
public class BankAccountCommand : ICommand
{
private BankAccount account;
public enum Action
{
Deposit,
Withdraw
}
private Action action;
private int amount;
public BankAccountCommand(BankAccount _account, Action _action, int _amount)
{
account = _account;
action = _action;
amount = _amount;
}
public void Call()
{
throw new NotImplementedException();
}
}
这个命令对象,对银行账户BankAccount的功能进行了封装。在构造函数里,传入了待操作的银行账户对象,操作类型存钱还是取现,以及相应的金额等。接下来根据上述信息,实现ICommand接口里的Call方法。
public void Call()
{
switch (action)
{
case Action.Deposit:
account.Deposit(amount);
break;
case Action.Withdraw:
account.Withdraw(amount);
break;
default:
break;
}
}
现在用起来如下:
var bankAccount = new BankAccount();
Console.WriteLine(bankAccount.ToString());
var depositCommand = new BankAccountCommand(bankAccount, BankAccountCommand.Action.Deposit, 100);
depositCommand.Call();
Console.WriteLine(bankAccount.ToString());
非常简单,先初始化了一个账户,然后新建了一个存款的命令,执行。可以看到如下输出结果。
balance:0
Deposit $100,balance now is 100
balance:100
可以看到我们通过命令模式,没有直接对原始的对象BankAccount进行操作,而是将需要操作的对象,以及操作的逻辑和参数都委托给了BankAccountCommand命令对象,使得我们可以直接在改命令对象内部添加逻辑而不必修改调用者和接受者,更加灵活。这里唯一遗憾的是我们无法阻止用户直接调用bankAccount自身的存款取款方法,这需要修改BankAccount对象自身。
撤销(Undo)操作
因为命令对象封装了所有的对BankAccount对象操作的方法和数据,所以很容易实现撤销操作,使对象恢复到操作之前的状态。
再添加撤销操作之前,我们需要将这一行为加入到之前定义的ICommand接口中,根据接口分离原则一般需要定义两个接口ICallable和IUndoable接口,这里为了简单方便,我们把Undo添加到ICommand接口里。
public interface ICommand
{
void Call();
void Undo();
}
在之前的银行账户存取款这个例子中,我们天真的简单的认为存款和取款是对称操作,存款的撤销操作就是取款,反之亦然。BankAccountCommand的Undo的实现如下:
public void Undo()
{
switch (action)
{
case Action.Deposit:
account.Withdraw(amount);
break;
case Action.Withdraw:
account.Deposit(amount);
break;
default:
break;
}
}
其实上述实现是有问题的,假设用户直接调用Undo,而之前没有调用Call,这就会有问题,所以我们需要一个状态来表示撤销操作之前的操作是否操作成功,如果操作成功才允许撤销。在这个例子中,我们默认存款操作是一直成功的。所以针对存款命令执行取款操作是没问题的,但是对于取款操作的撤销,需要判断之前的取款操作是否成功。
所以,这里需要在命令对象里增加bool型状态,保存撤销之前的操作是否成功。同时也需要将Withdraw方法的返回值从void改为bool型。
internal bool Withdraw(int amount)
{
if (balance - amount > overdraftLimit)
{
balance -= amount;
Console.WriteLine($"Withdraw ${amount},balance now is {balance}");
return true;
}
return false;
}
然后修改BankAccountCommand方法,增加bool型对象successed,来保存Call()操作的结果,并且在调用Undo之前判断该变量表示Call是否执行成功。
public class BankAccountCommand : ICommand
{
private bool isSuccessed;
private BankAccount account;
public enum Action
{
Deposit,
Withdraw
}
private Action action;
private int amount;
public BankAccountCommand(BankAccount _account, Action _action, int _amount)
{
account = _account;
action = _action;
amount = _amount;
}
public void Call()
{
switch (action)
{
case Action.Deposit:
account.Deposit(amount);
isSuccessed = true;
break;
case Action.Withdraw:
isSuccessed = account.Withdraw(amount);
break;
default:
break;
}
}
public void Undo()
{
if (!isSuccessed) return;
switch (action)
{
case Action.Deposit:
account.Withdraw(amount);
break;
case Action.Withdraw:
account.Deposit(amount);
break;
default:
break;
}
}
}
修改后的代码如上,现在我们进行如下操作,新建一个账户,先存100,然后取1000(因为超过透支额度,所以会失败),然后撤销取款,撤销存款。
var bankAccount = new BankAccount();
Console.WriteLine(bankAccount.ToString());
var depositCommand = new BankAccountCommand(bankAccount, BankAccountCommand.Action.Deposit, 100);
var withdrawCommand = new BankAccountCommand(bankAccount, BankAccountCommand.Action.Withdraw, 1000);
depositCommand.Call();
withdrawCommand.Call();
Console.WriteLine(bankAccount.ToString());
withdrawCommand.Undo();
depositCommand.Undo();
Console.WriteLine(bankAccount.ToString());
可以看到输出结果如下:
balance:0
Deposit $100,balance now is 100
balance:100
Withdraw $100,balance now is 0
balance:0
上面的例子演示了使用命令对象进行操作和撤销操作的方法,当然,也能在命令对象里添加其他的功能,比如当我们发现有很多次取款操作失败情况发生时,可以进行风险提示,是否账户异常等等。
组合命令模式
在银行账户模型中,还有一个比较常见的就是转账,A->B转账涉及到两个命令,首先从A取款,然后给B存款。要实现这一功能,可以调用上述的两个命令对象,也可以将这一逻辑封装成一个组合命令。
下面就来看组合命令,现在要实现转账逻辑,就需要在一个命令里包含多个命令,所以这里新建了一个抽象类CompositeBankAccountCommand,他继承自List<BankAccountCommand>和ICommand接口,代码如下:
public abstract class CompositeBankAccountCommand : List<BankAccountCommand>, ICommand
{
public virtual void Call()
{
ForEach(x => x.Call());
}
public virtual void Undo()
{
foreach (var item in ((IEnumerable<BankAccountCommand>)this).Reverse())
{
item.Undo();
}
}
}
可以看到,这个组合命令对象继承自List集合,并且实现了ICommand接口,在接口实现中Call方法一次调用集合内部对象的Call方法,Undo方法,逆序一次调用集合内部对象的Undo方法。这种逆序调用Undo是在抽象类中定义的,Call和Undo都是虚方法,可以在具体类中重写自己的实现。
现在我们新建转账逻辑的具体类。
public class MoneyTransferCommand : CompositeBankAccountCommand
{
public MoneyTransferCommand(BankAccount from, BankAccount to, int amount)
{
Add(new BankAccountCommand(from, BankAccountCommand.Action.Withdraw, amount));
Add(new BankAccountCommand(to, BankAccountCommand.Action.Deposit, amount));
}
}
上面的转账逻辑,重用了抽象类里的Call和Undo方法,但是对于转账这一业务逻辑来说,可能有问题。A给B转账,加入从A账户取钱失败,整个流程就应该结束,而不需要再给B账户存钱。要实现这个逻辑,需要重写Call逻辑,记录每一次状态,如果前一次操作结果为false,后续就不进行操作了。
private bool ok = true;
public override void Call()
{
foreach (var cmd in this)
{
if (ok)
{
cmd.Call();
ok = cmd.isSuccessed;
}
else
{
cmd.isSuccessed = false;
}
}
}
可以看到,加入前一次操作失败,后续操作就会终止,并且它的isSuccessed就会标记为false,这里标记为false的原因是在Undo的时候直接返回,所以Undo我们就可以直接服用抽象类里的逻辑,不需要修改。
现在我们执行从A给B转账的例子,这里假设A里面的钱不够,转账是会失败的。
var from = new BankAccount();
from.Deposit(100);
Console.WriteLine($"from {from.ToString()}");
var to = new BankAccount();
Console.WriteLine($"to {to.ToString()}");
var mtc = new MoneyTransferCommand(from, to, 1000);
mtc.Call();
Console.WriteLine($"from {from.ToString()}");
Console.WriteLine($"to {to.ToString()}");
执行结果如下:
from balance:100
to balance:0
from balance:100
to balance:0
可以看到转账失败,转账前后金额没有发生改变。
这里的情况发生在转账过程中,从A账户里取钱失败,但在某些情况下如果第一个步骤成功,往B账户里存钱失败这种场景,在目前的实现里则存在问题。
命令查询职责分离模式
在很久之前,我写过一篇文章 浅谈命令查询职责分离(CQRS)模式,命令查询分离的思想很简单,他把整个对系统的操作分为两类:
- 命令,这些命令能够改变系统的状态,但是没有返回值。
- 查询,有返回值,但是不会改变系统的状态。
命令查询模式中也用到了这里所说的命令模式。这里就不多说CQRS了,感兴趣可以查看上面这篇文章。
总结
命令模式很简单:就是当在不同模块之间进行交互的时候,建议使用特殊的对象来封装指令,而不是简单的调用方法并使用一些不同的参数来表示不同命令。命令模式在UI系统中用的很多,他能将一些典型的操作封装起来,然后在不同的地方以不同的方式被调用。比如在开始说的,可以通过顶级引用菜单、工具栏上的按钮、上下文菜单或者键盘快捷键开进行复制操作。 另外,一些动作可以组合成组合命令,一系列的命令对应的动作可以录制下来并随意执行,这就是我们通常所说的宏(Macros)。