<Go语言学习笔记> 常见设计模式

1,175 阅读11分钟

设计模式有什么用

如果你学过软件工程的话,应该会了解设计模式是什么,用来干什么。简单的讲,设计模式是经验总结,是针对某些特定场景的解决方案。 1995年,GOF提出了23种设计模式,在实际开发中使用到的种类非常少。设计模式有一个严肃的问题:增加代码量,降低代码可读性。当然了,这也是为了实现高内聚,低耦合付出的代价,想要得到一些,就得失去一些呗。

设计模式的灾难百分之九十九是开发人员的滥用。很多初学者,总是想把刚学会的设计模式用到自己的代码中,完全不考虑当前业务场景是否合适。于是,灾难悄无声息的酝酿起来了。

我们并不是说设计模式不好,我们要抨击这种故意不小心堆屎山的行为。

类似的,把不熟悉的三方包或者新技术引入到线上业务中;采用未经证实的方案解决业务问题;把只有自己会的技术或者代码引入到团队代码中等等,这类行为遗患无穷。

我们这里只会围绕常见业务,讲一讲最常见的三种设计模式。Go语言本身就是力求简洁,可靠的,在具体使用中也不建议使用一些复杂的设计模式。另外,在大多数情况下我们会选择多种设计模式相结合的方案解决实际业务场景。

引申,可以了解下23中设计模式都有哪些,常见的有哪些,它们的使用场景是什么。

工厂模式

工厂模式主要是将对象,也就是结构体的创建封装了起来,为使用者提供一个简单易用的方法来创建对象。对于Go语言来说,工厂模式用的非常少,最主要的是有一种画蛇添足的感觉。我们先看下工厂模式的基础:

type Pet struct {
	Name string
	Age  string 
}

func (p *Pet) String() {
	fmt.Printf(p.Name)
}

func NewPet(name, age string) *Pet {
	//典型的工厂模式用法,一般没人单独用
	return &Pet{
		Name: name,
		Age:  age,
	}
}

func testPet() {
	p := NewPet("关注香香编程喵喵喵,关注香香编程谢谢喵喵喵!", "1")

	//我直接声明它不香么
	p = &Pet{
		Name: "关注香香编程喵喵喵,关注香香编程谢谢喵喵喵!",
		Age:  "1",
	}
	//有些人会说你这样声明,没有工厂或者建造者模式优雅,可读性不好。
	//千万不要滥用设计模式,不要干那些画蛇添足的事儿。

	p.String()
}

Go的语法和声明已经足够简单,足够清楚了。这个世界上最难的事是把复杂问题简单化,简单问题认真化,就不要浪费时间把简单问题复杂化了,挺傻的。如果一个结构体复杂到一定程度了,会选择使用建造者模式。但,过于复杂的结构体不仅在实例化的时候麻烦,在具体调用的时候也很麻烦,可以做适当的拆分。

引申,可以看下建造者模式。在很极端的业务场景下,还是会用得到。

简单工厂

简单工厂区别于工厂模式,它返回一个interface。我们在多数情况下会使用简单工厂来构造一个结构体,以应对不同的场景。这种设计模式,在配置文件加载或者文件解析的时候非常常见。

type Config interface { //定义一个接口
	String()
}

type ConfigJson struct { //第一个接口实现
}

func (c *ConfigJson) String() {
}

func newConfigJson() *ConfigJson { //工厂模式
	return &ConfigJson{}
}

type ConfigYaml struct { //第二个接口实现
}

func (c *ConfigYaml) String() {
}

func newConfigYaml() *ConfigYaml {
	return &ConfigYaml{}
}

func testConfig(t string) Config { //这里一定是返回一个interface
	//根据传入的参数,选择对应的实例化对象,这是简单工厂的典型用法。
	switch t {
	case "json":
		return newConfigJson()
	case "yaml":
		return newConfigYaml()
	default:
		return nil
	}
}

这里又是经典的加一层思想。我们只需要提出要求,工厂模式会帮我选择合适的接口。

抽象工厂

按照抽象工厂的定义,我们上面的实现本质上就是抽象工厂。简单工厂用来生成某一个产品(实例),抽象工厂用来生成某一类产品(接口)。我们上面的案例实际上返回的就是一个接口。

单例

单例模式属于设计模式中最简单的一个,也是最常用的一个。我们在项目中为了避免频繁的申请一些链接(数据库,RPC等等)或者缓存(localcache),都会通过单例模式维护一个全局唯一的资源池。需要注意:不是全局唯一的场景都需要使用单例模式。单例模式最大的特点:全局唯一,有且只有一个。整个系统里,无论多少协程去调用这个结构体,都是同一个。

如果你有一定的开发经验的话,就能够意识到我们用单例模式去创建一个资源池的初衷。毕竟向操作系统申请和销毁一块资源的开销是非常大的;如果协程同时并发非常高的话,会创建大量相同的资源。通常情况下,我们会使用单例+连接池的方式解决这个问题。

切记,最好不要把单例的一些细节暴露给使用方。使用方在调用单例提供的资源时,也不要尝试去修改单例的一些内容。最典型的例子是:使用数据库连接的时候,随手关门把链接关闭掉了。

单例模式在实现上可以分为两种:懒汉式饿汉式

懒汉式单例

最常见的实现方法:


var GlobalMem *Mem //一个全局变量

type Mem struct {
	name string
	Age  string //不要这样定义字段,调用方能直接修改这个字段,不安全。
}

func (m *Mem) GetName() string {
	return m.name
}

func (m *Mem) SetName() {
	//最好不要提供这种方法,不要把单例的控制权交给任何调用方。
}

func NewGlobalMem() *Mem {
	//单例的核心方法
	if GlobalMem != nil {
		return GlobalMem
	}

	GlobalMem = &Mem{name: "关注香香编程喵喵喵,关注香香编程谢谢喵喵喵!"}
	return GlobalMem
}

func testMem() {
	//两种用法
	//1.直接在main里调用NewGlobalMem,此时全局GlobalMem已经被初始化了。
	_ = NewGlobalMem()
	name := GlobalMem.GetName()
	name = NewGlobalMem().GetName() //这两种方法都可以。
	fmt.Printf(name)

	//2.如果没有在main中初始化,后续直接调用NewGlobalMem即可。
	name = NewGlobalMem().GetName() //但是,这样子做有一个并发安全问题。如果同时10个协程使用,会创建10次。
	fmt.Printf(name)
}

通常情况下,我们会在服务中使用懒汉式的第一种用法,这种用法变相的将懒汉式变成了饿汉式,也不用担心并发安全的问题。第二种用法是懒汉式的典型用法和典型并发不安全问题,解决办法是加锁或者双重校验。在Go语言中,我们可以直接使用sync.Once进行一次封装。

var once = &sync.Once{}

func NewGlobalMemOnce() *Mem {
	once.Do(func() {
		//sync.Once 可以保证无论多少并发和调用,这个实例化只会被执行一次。
		GlobalMem = &Mem{name: "关注香香编程喵喵喵,关注香香编程谢谢喵喵喵!"}
	})
	return GlobalMem
}

饿汉式单例

//这里省去了其他多余的代码
var GlobalMem1 = &Mem{name: "关注香香编程喵喵喵,关注香香编程谢谢喵喵喵!"}//在声明GlobalMem1的时候,直接把它实现掉。
func testMem1() {
	//后续直接使用即可,不需要实例化,也不存在并发安全问题。
	name := GlobalMem1.GetName()
	fmt.Printf(name)
}

饿汉式单例不存在并发安全问题,但依然不常使用。它不会管你是否使用这个资源,都会帮你实例化,很浪费资源。

云部署很好用,但会掩盖一些内存使用上的问题,建议新手一开始就建立良好的编程习惯。对于Go而言,要特别关注代码的耗时,内存使用和协程数。

观察者模式

观察者模式本质上使用的是生产者与消费者的思想,一个对象通过观察另一个对象的状态变化,来做出适当的反应。常用的场景是一个对象将自己某个状态的变化告知给一群观察者。我们实现一个非常标准的观察者:

type Observer interface { // 观察者接口,也叫订阅者
	OnCall(int) //触发响应的方法
}

type Notifier interface { // 被观察者,也叫发布者
	Register(*Observer)   //把一个观察者注册进来
	Deregister(*Observer) //移除一个观察者,有些实现中没有移除的方法,这样会不完整,缺了一点灵活性
	Notify(int)           //发出消息,通知观察者们状态变化,int 来代表一个状态
}

// NotifierOne 实现一个发布者
type NotifierOne struct {
	//observers []Observer //如果不需要移除操作,那么使用切片就好
	observers map[Observer]struct{} //需要移除的话,显然使用MAP更合适。
	//注意这里有两个细节,Key使用了interface,但是在接收的时候使用的是指针,Value是空结构体。我们之前都分享过这些细节。
	status int //被观察的状态
}

func (n *NotifierOne) Register(o Observer) {
	n.observers[o] = struct{}{} //将观察者装进Map
}

func (n *NotifierOne) Deregister(o Observer) {
	delete(n.observers, o)
}

func (n *NotifierOne) Notify(status int) {
	n.status = status
	for observer, _ := range n.observers {
		//逐个调用MAP中观察者的OnCall 方法,以此来实现通知操作。
		observer.OnCall(status)
		//这样也可以,通过并发的方式进行调用。但,任何问题只要开启并发,就会引入其他问题,需要提前想清楚
		go observer.OnCall(status)
	}
}

// Observer1 观察者1号
type Observer1 struct {
}

func (o *Observer1) OnCall(status int) {
	//不同的观察者,可以做不同的事情,也可以做相同的事情。
	fmt.Printf("关注香香编程喵喵喵!Status:%d\n", status)
}

// Observer2 观察者2号
type Observer2 struct {
}

func (o *Observer2) OnCall(status int) {
	fmt.Printf("关注香香编程谢谢喵喵喵!Status:%d\n", status)
}

func testObs() {
	//实例化一个被观察者
	notifier := &NotifierOne{
		observers: make(map[Observer]struct{}),
		status:    0,
	}

	//实例化两个观察者
	o1 := Observer1{}
	o2 := Observer2{}
	notifier.Register(&o1)
	notifier.Register(&o2)

	fmt.Printf("observers len:%d\n", len(notifier.observers)) // 2
	//触发通知
	notifier.Notify(1)
}

我们实现了一个标准的观察者模式,使用了切片或者MAP存储订阅者的信息,这里就有一个安全隐患。它们都无法实现并发安全,也就是说,不能在通知所有订阅的同时,添加或者删除一个订阅者,会出问题。另外,使用并发的方式调用订阅者的方法,我们是无法轻易控制并发顺序的。如果短时间内多次频繁变更状态,订阅者可能会出现并发安全问题。

另外,我们可以使用sync.Cand进行优化:

type eveFun func(a *Event, cond *sync.Cond) //订阅方法

type Event struct { //利用这个结构体,作为状态的载体
	Status int
	FG     int //计数器
	sc     *sync.Cond
	fs     []eveFun //订阅方法的数组
}

func (e *Event) Run() {
	for _, f2 := range e.fs {
		go f2(e, e.sc) //让订阅方法跑起来
	}
}

func (e *Event) Notify(s int) {
	e.sc.L.Lock()
	e.FG = len(e.fs) //重置计数器
	e.Status = s
	e.sc.L.Unlock()

	e.sc.Broadcast() //通知所有等待的订阅者

	for {
		if e.FG <= 0 {
			e.Run() //当订阅者全部运行完成后,在开启下一轮监听
			//注意这里有BUG,假如某个订阅者阻塞了,那么这里永远不会开启下一轮
			//通知方法也会卡在这个FOR语句中,最后彻底夯住。
			return
		}
	}
}

func OnCall1(e *Event, cond *sync.Cond) {
	cond.L.Lock()
	for e.FG <= 0 {
		cond.Wait() //当没有开启通知时,先暂时进入等待队列
	}
	fmt.Printf("关注香香编程喵喵喵!Status:%d\n", e.Status)
	e.FG--          //执行业务逻辑
	cond.L.Unlock() //解锁
}

func OnCall2(e *Event, cond *sync.Cond) {
	cond.L.Lock()
	for e.FG <= 0 {
		cond.Wait()
	}
	fmt.Printf("关注香香编程谢谢喵喵喵!Status:%d\n", e.Status)
	e.FG--
	cond.L.Unlock()
}

func testObsCond() {
	l := &sync.Mutex{}
	e := &Event{
		sc: sync.NewCond(l),
		fs: []eveFun{OnCall1, OnCall2},
	}
	e.Run()

	e.Notify(1)
	e.Notify(2)
	e.Notify(3)
}

注意,这里的实现方式仍然存在边界Case的问题,这里只提供了一个思路。

其他

设计模式一直以来都是比较尴尬的存在: 对于初学者而言,很难理解其中的概念和关系,搞不清楚它们之间的区别和具体的场景。容易出现用错,或者滥用的场景。 对于有一些经验的工程师而言,大多数设计模式其实比较鸡肋,一般用不到,用到不一般。很难在代码的可读性和易扩展上取得一个平衡。 所以,其实没有必要花太多时间在设计模式上,掌握最常用的几个,并深入理解就好。