大家好,这里是每周都陪你进步的网管,假期归来咱们继续更新设计模式系列,这次要和大家一起学习的是命令模式,如果你对领域驱动设计感兴趣,这个模式一定要好好学,命令模式是DDD风格的框架中高频使用的一个模式。
命令模式是一种行为型模式。它通过将请求封装为一个独立的对象即命令对象,来解耦命令的调用者和接收者,使得调用者和接收者不直接交互。在命令对象里会包含请求相关的全部信息,每一个命令都是一个操作的请求: 请求方发出请求要求执行一个操作; 接收方收到请求,并执行操作。
命令模式的构成
命令模式中有如下必须存在的基础组件:
- Receiver:命令的接收方,唯一包含业务逻辑的类,命令对象会将请求传递给它,它是请求的最终处理者
- Command:命令对象,组装了一个Receiver成员,并绑定实现了Receiver的一个特定行为的调用
- Invoker:请求的发送者,组装了Command成员,通过调用Command实例的execute()方法来触发对应的指令
- Client:通过将Receiver实例和请求信息传递给Command构造器来创建Command对象,之后会将创建的对象同Invoker绑定。
直接这么描述听起来比较抽象,下面我们结合UML类图详细看一下命令模式内部这几种基础组件的特性和具有的行为。
UML类图
命令模式的构成如下图所示 请求的接收者Receiver我们做了简化,根据实际场景复杂度的需要我们也可以进一步抽象出接口和实现类,图中表示的命令模式一共由五种角色构成,下面详细解释下它们各自的特性和具有的行为
- 发送者(Invoker)负责对请求进行初始化, 其中必须包含一个成员变量来存储对于命令对象的引用。 发送者触发命令, 而不是向接收者直接发送请求。 发送者并不负责创建命令对象,而是由客户端负责调用构造函数创建命令对象。
- 命令接口(Command) 通常接口中仅声明一个执行命令的方法 Execute()。
- 具体命令 (Concrete Commands) 会实现各种类型的请求。 命令对象自身并不完成工作, 而是会将调用委派给一个接收者对象。 接收者对象执行方法所需的参数可以声明为具体命令的成员变量。 一般会约定命令对象为不可变对象, 仅允许通过构造函数对这些成员变量进行初始化。
- 接收者 (Receiver) 处理业务逻辑的类。 几乎任何对象都可以作为接收者。 命令对象只负责处理如何将请求传递到接收者的细节, 接收者自己会完成实际的工作。
- 客户端 (Client) 会创建并配置具体命令对象。 客户端必须将包括接收者对象在内的所有请求参数传递给命令对象的构造函数, 完成命令与执行操作的接收者的关联。
发送者是通常我们能接触到的终端,比如电视的遥控器,点击音量按钮发送加音量的命令,电视机里的芯片就是接收者负责完成音量添加的处理逻辑。
下面我们通过一个让PS5完成各种操作的例子,结合Golang代码实现理解一下用代码怎么实现命令模式。
代码示例
假设PS5的CPU支持A、B、C三个命令操作,
go
"本文使用的完整可运行源码
去公众号「网管叨bi叨」发送【设计模式】即可领取"
type CPU struct{}
func (CPU) ADoSomething() {
fmt.Println("a do something")
}
func (CPU) BDoSomething() {
fmt.Println("b do something")
}
type PS5 struct {
cpu CPU
}
func (p PS5) ACommand() {
p.cpu.ADoSomething()
}
func (p PS5) BCommand() {
p.cpu.ADoSomething()
}
func main() {
cpu := CPU{}
ps5 := PS5{cpu}
ps5.ACommand()
ps5.BCommand()
}
后续还可能会给CPU增加其他命令操作,以及需要支持命令宏(即命令组合操作)。如果每次都修改PS5的类定义,显然不符合面向对象开闭原则(Open close principle)的设计理念。
通过命令模式,我们把PS5抽象成命令发送者、CPU对象作为执行业务逻辑的命令接收者,然后引入引入Command 接口把两者做解耦,来满足开闭原则。
下面看一下用命令模式解耦后的代码实现,模式中各个角色的职责、实现思路等都在代码注释里做了标注,咱们直接看代码吧。
go
"本文使用的完整可运行源码
去公众号「网管叨bi叨」发送【设计模式】即可领取"
// 命令接收者,负责逻辑的执行
type CPU struct{}
func (CPU) ADoSomething(param int) {
fmt.Printf("a do something with param %v\n", param)
}
func (CPU) BDoSomething(param1 string, param2 int) {
fmt.Printf("b do something with params %v and %v \n", param1, param2)
}
func (CPU) CDoSomething() {
fmt.Println("c do something with no params")
}
// 接口中仅声明一个执行命令的方法 Execute()
type Command interface {
Execute()
}
// 命令对象持有一个指向接收者的引用,以及请求中的所有参数,
type ACommand struct {
cpu *CPU
param int
}
// 命令不会进行逻辑处理,调用Execute方法会将发送者的请求委派给接收者对象。
func (a ACommand) Execute() {
a.cpu.ADoSomething(a.param)
a.cpu.CDoSomething()// 可以执行多个接收者的操作完成命令宏
}
func NewACommand(cpu *CPU, param int) Command {
return ACommand{cpu, param}
}
"本文使用的完整可运行源码
去公众号「网管叨bi叨」发送【设计模式】即可领取"
type BCommand struct {
state bool // Command 里可以添加些状态用作逻辑判断
cpu *CPU
param1 string
param2 int
}
func (b BCommand) Execute() {
if b.state {
return
}
b.cpu.BDoSomething(b.param1, b.param2)
b.state = true
b.cpu.CDoSomething()
}
func NewBCommand(cpu *CPU, param1 string, param2 int) Command {
return BCommand{false,cpu, param1, param2}
}
type PS5 struct {
commands map[string]Command
}
// SetCommand方法来将 Command 指令设定给PS5。
func (p *PS5) SetCommand(name string, command Command) {
p.commands[name] = command
}
// DoCommand方法选择要执行的命令
func (p *PS5) DoCommand(name string) {
p.commands[name].Execute()
}
func main() {
cpu := CPU{}
// main方法充当客户端,创建并配置具体命令对象, 完成命令与执行操作的接收者的关联。
ps5 := PS5{make(map[string]Command)}
ps5.SetCommand("a", NewACommand(&cpu, 1))
ps5.SetCommand("b", NewBCommand(&cpu, "hello", 2))
ps5.DoCommand("a")
ps5.DoCommand("b")
}
本文的完整源码,已经同步收录到我整理的电子教程里啦,可向我的公众号「网管叨bi叨」发送关键字【设计模式】领取。
总结
关于命令模式的学习和实践应用,推荐有Java背景的同学看一下阿里开源的框架COLA 3.0,里面融合了不少DDD的概念,其中的Application层主要就是各种Command、Query对象封装了客户端的请求,它们的Execute方法负责将请求转发给Domain层进行处理从而完成业务逻辑。
最后我们再来总结一下命令模式的优缺点。
命令模式的优点
- 通过引入中间件(抽象接口),解耦了命令请求与实现。
- 扩展性良好,可以很容易地增加新命令。
- 支持组合命令,支持命令队列。
- 可以在现有命令的基础上,增加额外功能。比如日志记录,结合装饰器模式会更加灵活。
命令模式的缺点
- 具体命令类可能过多。
- 命令模式的结果其实就是接收方的执行结果,但是为了以命令的形式进行架构、解耦请求与实现,引入了额外类型结构(引入了请求方与抽象命令接口),增加了理解上的困难。