简介
设计模式是面向对象软件的设计经验,是解决特定问题的一系列套路。它不是语法规定,而是一套用来提高代码可复用性、可维护性、可读性、稳健性以及安全性的解决方案。每一种设计模式系统的命名、解释和评价了面向对象中一个重要的和重复出现的设计。
行为模式 主要关注对象之间的通信,有以下几种:
- 模版模式(Template Pattern)
- 命令模式(Command Pattern)
- 迭代器模式(Iterator Pattern)
- 观察者模式(Observer Pattern)
- 中介者模式(Mediator Pattern)
- 备忘录模式(Memento Pattern)
- 解释器模式(Interpreter Pattern)
- 状态模式(State Pattern)
- 策略模式(Strategy Pattern)
- 责任链模式(Chain of Responsibility Pattern)
- 访问者模式(Visitor Pattern)
模版模式
通俗解释
看过《如何说服女生上床》这部经典文章吗?女生从认识到上床的不变的步骤分为巧遇、打破僵局、展开追求、接吻、前戏、动手、爱抚、进去八大步骤 (Template method),但每个步骤针对不同的情况,都有不一样的做法,这就要看你随机应变啦 (具体实现);
模板模式:模板模式准备一个抽象类,将部分逻辑以具体方法以及具体构造子的形式实现,然后声明一些抽象方法来迫使子类实现剩余的逻辑。不同的子类可以以不同的方式实现这些抽象方法,从而对剩余的逻辑有不同的实现。先制定一个顶级逻辑框架,而将逻辑的细节留给具体的子类去实现。
概念
定义一个模板结构,将具体内容延迟到子类去实现。模板控制流程,子类负责实现。
**模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。**TemplateMethod是算法骨架,PrimitiveMethod1和PrimitiveMethod2是骨架中的某些步骤。
在模板模式经典的实现中,模板方法定义为 final,可以避免被子类重写。需要子类重写的方法定义为 abstract,可以强迫子类去实现。
以前用这种定义好算法骨架,具体实现在不同子类的方案时,一般使用的是工厂方法加代理模式。工厂方法能够提供更多的灵活性,但如果一个算法骨架中有10个具体算法,总不能让工厂生产10个不同的对象吧。所以如果算法骨架中有多个具体算法,而这些算法又是高内聚的,用模板模式就很合适。
应用场景
业务开发场景中,模板模式使用频率并不高,但是在框架方面,还是使用的比较频繁的。
- 有多个子类共有的方法,且逻辑相同。
- 重要的、复杂的方法,可以考虑作为模板方法。
优点
- 封装不变部分,扩展可变部分。
- 提取公共代码,便于维护。
- 行为由父类控制,子类实现。
缺点
每一个不同的实现都需要一个子类来实现,导致类的个数增加,使得系统更加庞大
实例演示
假设我现在要做一个短信推送的系统,那么需要
- 检查短信字数是否超过限制
- 检查手机号是否正确
- 发送短信
- 返回状态
我们可以发现,在发送短信的时候由于不同的供应商调用的接口不同,所以会有一些实现上的差异,但是他的算法(业务逻辑)是固定的
代码实现:
package template
import "fmt"
// ISMS ISMS
type ISMS interface {
send(content string, phone int) error
}
// SMS 短信发送基类
type sms struct {
ISMS
}
// Valid 校验短信字数
func (s *sms) Valid(content string) error {
if len(content) > 63 {
return fmt.Errorf("content is too long")
}
return nil
}
// Send 发送短信
func (s *sms) Send(content string, phone int) error {
if err := s.Valid(content); err != nil {
return err
}
// 调用子类的方法发送短信
return s.send(content, phone)
}
// TelecomSms 走电信通道
type TelecomSms struct {
*sms
}
// NewTelecomSms NewTelecomSms
func NewTelecomSms() *TelecomSms {
tel := &TelecomSms{
}
// 这里有点绕,是因为 go 没有继承,用嵌套结构体的方法进行模拟
// 这里将子类作为接口嵌入父类,就可以让父类的模板方法 Send 调用到子类的函数
// 实际使用中,我们并不会这么写,都是采用组合+接口的方式完成类似的功能
tel.sms = &sms{
ISMS: tel}
return tel
}
func (tel *TelecomSms) send(content string, phone int) error {
fmt.Println("send by telecom success")
return nil
}
单元测试:
package template
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_sms_Send(t *testing.T) {
tel := NewTelecomSms()
err := tel.Send("test", 1239999)
assert.NoError(t, err)
}
总结
模板模式有两大作用:复用和扩展。其中,复用指的是,所有的子类可以复用父类中提供的模板方法的代码。扩展指的是,框架通过模板模式提供功能扩展点,让框架用户可以在不修改框架源码的情况下,基于扩展点定制化框架的功能。
命令模式
通俗解释
俺有一个 MM 家里管得特别严,没法见面,只好借助于她弟弟在我们俩之间传送信息,她对我有什么指示,就写一张纸条让她弟弟带给我。这不,她弟弟又传送过来一个 COMMAND,为了感谢他,我请他吃了碗杂酱面,哪知道他说:“我同时给我姐姐三个男朋友送 COMMAND,就数你最小气,才请我吃面。”
命令模式:命令模式把一个请求或者操作封装到一个对象中。命令模式把发出命令的责任和执行命令的责任分割开,委派给不同的对象。命令模式允许请求的一方和发送的一方独立开来,使得请求的一方不必知道接收请求的一方的接口,更不必知道请求是怎么被接收,以及操作是否执行,何时被执行以及是怎么被执行的。系统支持命令的撤消。
概念
命令模式是一个高内聚的模式,将一个请求封装成一个对象,从而让你使用不同的请求把客户端参数化,对请求排队或者记录日志,可以提供命令的撤销和恢复功能。命令模式的核心在于引入了命令类,通过命令类来降低发送者和接收者的耦合度,请求发送者只需指定一个命令对象,再通过命令对象来调用请求接收者的处理方法。
首先我们需要明白什么是命令。命令包括指令和数据。指令是行为,数据影响到指令。如前进3米,前进是指令,3米是数据。
然后我们再看一下各个类的含义。
Command
和ConcreteCommand
是命令,有Excute
函数,代表要做的行为。
ConcreteCommand
调用Excute()
,最终调用Receiver
的Action
。这意味ConcreteCommand
只是一个容器,真正的操作逻辑在Receiver中。
Invoker
包含了所有Command
,控制Command
何时执行Excute()
。
现在我们将上图简化,把Invoker
、Receiver
去掉,看看是否容易理解了:
通过这个简洁版示意图,我们来看一下为什么要用命令模式:
- 命令包括指令和数据,指令其实对应着操作,操作在代码中对应着函数。
- 命令模式其实是把函数封装成对象,系统能对对象进行各种操作,如排队执行、记录日志、撤销等。
为什么要将函数包装成对象呢?C、C++、Go支持函数指针,但并不是所有语言都有这种特性,这时命令模式就起作用了。而且即使语言支持函数指针,命令的数据部分怎么存放仍是一个问题。
所以简单理解,命令模式就是把请求打包成一个一个Command对象,存储起来,系统根据实际需求进行处理。
应用场景
大家可能感觉命令模式与MQ、工厂模式一样,其实在细节上是有区别的:
- MQ只包含数据,不包含行为,命令模式两者都包含
- 工厂模式需要实时执行,但命令模式可以进行存储,延后执行
优点
- 类间解耦,调用者角色与接收者角色之间没有任何依赖关系,调用者实现功能时只需调用Command抽象类的execute方法就可以,不需要了解到底是哪个接收者执行。
- 可扩展性,Command的子类可以非常容易的扩展,而调用者Invoker和高层次的模块Client不产生严重的代码耦合。
- 命令模式结合其他模式,命令模式可以结合责任链模式,实现命令族解析任务;结合模板方法模式,则可以减少Command子类的膨胀问题。
缺点
如果有N个命令,那么Command子类就有N个,这个类将膨胀得非常大。
实例演示
接下来会有两个例子,第一个是按照原文定义的方式,将函数封装成对象,第二个例子我们直接将函数作为参数传递。
1 将函数封装为对象
假设现在有一个游戏服务,我们正在实现一个游戏后端,使用一个 goroutine 不断接收来自客户端请求的命令,并且将它放置到一个队列当中,然后我们在另外一个 goroutine 中来执行它
代码实现:
package command
import "fmt"
// ICommand 命令
type ICommand interface {
Execute() error
}
// StartCommand 游戏开始运行
type StartCommand struct{
}
// NewStartCommand NewStartCommand
func NewStartCommand( /*正常情况下这里会有一些参数*/ ) *StartCommand {
return &StartCommand{
}
}
// Execute Execute
func (c *StartCommand) Execute() error {
fmt.Println("game start")
return nil
}
// ArchiveCommand 游戏存档
type ArchiveCommand struct{
}
// NewArchiveCommand NewArchiveCommand
func NewArchiveCommand( /*正常情况下这里会有一些参数*/ ) *ArchiveCommand {
return &ArchiveCommand{
}
}
// Execute Execute
func (c *ArchiveCommand) Execute() error {
fmt.Println("game archive")
return nil
}
单元测试:
package command
import (
"fmt"
"testing"
"time"
)
func TestDemo(t *testing.T) {
// 用于测试,模拟来自客户端的事件
eventChan := make(chan string)
go func() {
events := []string{
"start", "archive", "start", "archive", "start", "start"}
for _, e := range events {
eventChan <- e
}
}()
defer close(eventChan)
// 使用命令队列缓存命令
commands := make(chan ICommand, 1000)
defer close(commands)
go func() {
for {
// 从请求或者其他地方获取相关事件参数
event, ok := <-eventChan
if !ok {
return
}
var command ICommand
switch event {
case "start":
command = NewStartCommand()
case "archive":
command = NewArchiveCommand()
}
// 将命令入队
commands <- command
}
}()
for {
select {
case c := <-commands:
c.Execute()
case <-time.After(1 * time.Second):
fmt.Println("timeout 1s")
return
}
}
}
2 将函数直接作为参数
假设现在有一个游戏服务,我们正在实现一个游戏后端,使用一个 goroutine 不断接收来自客户端请求的命令,并且将它放置到一个队列当中,然后我们在另外一个 goroutine 中来执行它
代码实现:
package command
import "fmt"
// Command 命令
type Command func() error
// StartCommandFunc 返回一个 Command 命令
// 是因为正常情况下不会是这么简单的函数
// 一般都会有一些参数
func StartCommandFunc() Command {
return func() error {
fmt.Println("game start")
return nil
}
}
// ArchiveCommandFunc ArchiveCommandFunc
func ArchiveCommandFunc() Command {
return func() error {
fmt.Println("game archive")
return nil
}
}
单元测试:
package command
import (
"fmt"
"testing"
"time"
)
func TestDemoFunc(t *testing.T) {
// 用于测试,模拟来自客户端的事件
eventChan := make(chan string)
go func() {
events := []string{
"start", "archive", "start", "archive", "start", "start"}
for _, e := range events {
eventChan <- e
}
}()
defer close(eventChan)
// 使用命令队列缓存命令
commands := make(chan Command, 1000)
defer close(commands)
go func() {
for {
// 从请求或者其他地方获取相关事件参数
event, ok := <-eventChan
if !ok {
return
}
var command Command
switch event {
case "start":
command = StartCommandFunc()
case "archive":
command = ArchiveCommandFunc()
}
// 将命令入队
commands <- command
}
}()
for {
select {
case c := <-commands:
c()
case <-time.After(1 * time.Second):
fmt.Println("timeout 1s")
return
}
}
}
总结
设计模式是为了解决现实中的问题,我们需要和具体场景相绑定。在解决问题的时候,采用的是不是标准的设计模式并不重要,模式只是手段,手段需要为达成目的服务。
迭代器模式
通俗解释
我爱上了 Mary,不顾一切的向她求婚。Mary:“想要我跟你结婚,得答应我的条件” 我:“什么条件我都答应,你说吧” Mary:“我看上了那个一克拉的钻石” 我:“我买,我买,还有吗?” Mary:“我看上了湖边的那栋别墅” 我:“我买,我买,还有吗?” Mary:“我看上那辆法拉利跑车” 我脑袋嗡的一声,坐在椅子上,一咬牙:“我买,我买,还有吗?”
迭代模式:迭代模式可以顺序访问一个聚集中的元素而不必暴露聚集的内部表象。多个对象聚在一起形成的总体称之为聚集,聚集对象是能够包容一组对象的容器对象。迭代子模式将迭代逻辑封装到一个独立的子对象中,从而与聚集本身隔开。
迭代模式简化了聚集的界面。每一个聚集对象都可以有一个或一个以上的迭代子对象,每一个迭代子的迭代状态可以是彼此独立的。迭代算法可以独立于聚集角色变化。
概念
迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。把游走的任务放在迭代器上,而不是聚合上。这样简化了聚合的接口和实现,也让责任各得其所。
角色:
- 抽象聚合(Aggregate)角色:定义存储、添加、删除聚合对象以及创建迭代器对象的接口。
- 具体聚合(ConcreteAggregate)角色:实现抽象聚合类,返回一个具体迭代器的实例。
- 抽象迭代器(Iterator)角色:定义访问和遍历聚合元素的接口,通常包含 hasNext()、first()、next() 等方法。
- 具体迭代器(Concretelterator)角色:实现抽象迭代器接口中所定义的方法,完成对聚合对象的遍历,记录遍历的当前位置。
分析:
通过上图可以看出,对于集合Aggregate,其遍历能力被拆了出来,由Iterator负责遍历。
大家可能有疑问,可以用for循环解决的问题,为啥要搞得这么复杂呢?
其实主要看集合结构的复杂性,如果是普通的数组,可以不需要Iterator,直接使用for循环即可。如果是复杂的集合呢?对于这个集合需要有多种遍历方案呢?
如对于图结构,有广度优先、深度优先两种遍历方式,都在图集合里实现,是否感觉违背了职责单一原则。
所以对于复杂结构,迭代器有如下优势:
- 这种拆分,使集合和迭代器职责更加单一,符合单一职责原则
- 迭代器结构统一,方便使用,使用者无需知道遍历细节便可遍历集合
- 符合开闭原则,可以按照需求自己开发迭代器,无需改动集合类
- 符合里氏替换原则,可以方便的进行迭代方案的更换
通过上面示意图可发现设计思路:迭代器中需要定义first()、isDone()、currentItem()、next() 四个最基本的方法。待遍历的集合对象通过依赖注入传递到迭代器类中。集合可通过CreateIterator() 方法来创建迭代器。
应用场景
迭代器模式一般在library中使用的比较多,毕竟library提供的大多是基础结构。实际业务场景中,很少需要自己编写迭代器。但代码还是要写的,这次写图集合与深度优先遍历迭代器,大家如果对其它类型的图迭代器感兴趣的话,可自行编写。
优点
- 它支持以不同的方式遍历一个聚合对象。
- 迭代器简化了聚合类。
- 在同一个聚合上可以有多个遍历。
- 在迭代器模式中,增加新的聚合类和迭代器类都很方便,无须修改原有代码。