常见设计模式

3/27/2024 Go解析

# 设计模式有什么用

设计模式是经验总结,是针对某些特定场景的解决方案。 1995年,GOF提出了23种设计模式,在实际开发中使用到的种类非常少。设计模式有一个严肃的问题:增加代码量,降低代码可读性。当然了,这也是为了实现高内聚,低耦合付出的代价,在项目中应用需要做到合理的取舍。

如果设计模式的使用完全不考虑当前业务场景是否合适,会造成灾难悄无声息的酝酿。类似的,把不熟悉的三方包或者新技术引入到线上业务中;采用未经证实的方案解决业务问题;把只有自己会的技术或者代码引入到团队代码中等等,这类行为遗患无穷。

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

# 工厂模式

工厂模式主要是将对象,也就是结构体的创建封装了起来,为使用者提供一个简单易用的方法来创建对象。对于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()
}
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
30

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
	}
}
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
30
31
32
33
34
35

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

# 抽象工厂模式

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

# 单例模式

单例模式属于设计模式中最简单的一个,也是最常用的一个。我们在项目中为了避免频繁的申请一些链接(数据库,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)
}
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
30
31
32
33
34
35
36
37

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

var once = &sync.Once{}

func NewGlobalMemOnce() *Mem {
	once.Do(func() {
		//sync.Once 可以保证无论多少并发和调用,这个实例化只会被执行一次。
		GlobalMem = &Mem{name: "汪汪!"}
	})
	return GlobalMem
}
1
2
3
4
5
6
7
8
9

# 饿汉式单例模式

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

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

云部署很好用,但会掩盖一些内存使用上的问题,建议新手一开始就建立良好的编程习惯。对于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)
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

我们实现了一个标准的观察者模式,使用了切片或者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)
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

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

# 其他

# 23中设计模式都有哪些,常见的有哪些,它们的使用场景是什么?

创建型模式:

  1. 工厂方法模式 (Factory Method Pattern):定义一个创建对象的接口,但让子类决定实例化哪个类。使用场景包括需要根据某些条件来动态创建对象的情况。

  2. 抽象工厂模式 (Abstract Factory Pattern):提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。使用场景包括需要创建一组相关对象的情况,如不同操作系统下的界面组件。

  3. 单例模式 (Singleton Pattern):确保一个类只有一个实例,并提供全局访问点。常见使用场景包括线程池、缓存、日志对象等。

  4. 建造者模式 (Builder Pattern):将一个复杂对象的构建与其表示分离,使同样的构建过程可以创建不同的表示。使用场景包括创建复杂对象的过程较为固定但具有多样化的情况。

  5. 原型模式 (Prototype Pattern):通过复制现有对象来创建新对象,而不是使用构造函数。使用场景包括创建对象成本较高、对象初始化复杂、需要避免构造函数的情况。

结构型模式:

  1. 适配器模式 (Adapter Pattern):将一个类的接口转换成客户希望的另一个接口。使用场景包括需要使用已有的类,但其接口与所需接口不符的情况。

  2. 桥接模式 (Bridge Pattern):将抽象部分与实现部分分离,使它们可以独立变化。使用场景包括需要多维度变化的情况,如不同颜色和形状的图形。

  3. 组合模式 (Composite Pattern):将对象组合成树形结构以表示“部分-整体”的层次结构。使用场景包括处理树形结构数据的情况,如文件系统。

  4. 装饰者模式 (Decorator Pattern):动态地给对象添加额外的职责,而不影响其接口。使用场景包括需要在不修改原有对象代码的情况下添加新功能。

  5. 外观模式 (Facade Pattern):为子系统中的一组接口提供一个统一的接口。使用场景包括简化复杂系统的使用,提供更简洁的接口。

  6. 享元模式 (Flyweight Pattern):通过共享实例来尽量减少内存使用和提高性能。使用场景包括需要创建大量相似对象的情况,如游戏中的粒子系统。

  7. 代理模式 (Proxy Pattern):为其他对象提供一种代理以控制对这个对象的访问。使用场景包括需要在访问对象时添加额外控制或者屏蔽原对象的情况,如远程代理、虚拟代理等。

行为型模式:

  1. 责任链模式 (Chain of Responsibility Pattern):将请求的发送者和接收者解耦,使多个对象都有机会处理这个请求。使用场景包括需要处理的请求具有多个处理者,但不确定由谁来处理的情况。

  2. 命令模式 (Command Pattern):将请求封装成对象,从而使我们可以用不同的请求对客户进行参数化。使用场景包括需要将请求、调用、操作等封装成对象以便进行参数化、传递、调用和执行的情况。

  3. 解释器模式 (Interpreter Pattern):给定一个语言,定义它的文法的一种表示,并定义一个解释器,使用该解释器来解释语言中的句子。使用场景包括需要构建一个简单的语言解释器或者编译器的情况。

  4. 迭代器模式 (Iterator Pattern):提供一种方法顺序访问一个聚合对象中各个元素,而又不暴露该对象的内部表示。使用场景包括需要访问聚合对象的元素并且不想暴露其内部结构的情况。

  5. 中介者模式 (Mediator Pattern):用一个中介对象来封装一系列对象之间的交互。使用场景包括对象之间存在复杂的关联关系,导致相互依赖,难以维护的情况。

  6. 备忘录模式 (Memento Pattern):在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。使用场景包括需要实现对象状态的备份与恢复的情况。

  7. 观察者模式 (Observer Pattern):定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。使用场景包括当一个对象的改变需要同时改变其它对象,并且不知道具体有多少对象需要改变时。

  8. 状态模式 (State Pattern):允许对象在其内部状态

# 建造者模式

package main

import "fmt"

// Product 表示要构建的复杂对象
type Product struct {
	part1 string
	part2 int
}

// Builder 接口定义了构建产品的方法
type Builder interface {
	buildPart1()
	buildPart2()
	getProduct() Product
}

// ConcreteBuilder 实现了 Builder 接口
type ConcreteBuilder struct {
	product Product
}

func (cb *ConcreteBuilder) buildPart1() {
	cb.product.part1 = "Part 1 of the product"
}

func (cb *ConcreteBuilder) buildPart2() {
	cb.product.part2 = 123
}

func (cb *ConcreteBuilder) getProduct() Product {
	return cb.product
}

// Director 负责使用 Builder 构建产品
type Director struct {
	builder Builder
}

func (d *Director) setBuilder(builder Builder) {
	d.builder = builder
}

func (d *Director) constructProduct() Product {
	d.builder.buildPart1()
	d.builder.buildPart2()
	return d.builder.getProduct()
}

func main() {
	// 创建 Director 和 ConcreteBuilder
	director := Director{}
	builder := &ConcreteBuilder{}

	// Director 使用 ConcreteBuilder 构建产品
	director.setBuilder(builder)
	product := director.constructProduct()

	// 使用构建好的产品
	fmt.Println("Product Part 1:", product.part1)
	fmt.Println("Product Part 2:", product.part2)
}
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

在这个示例中,我们定义了一个 Product 结构体表示要构建的复杂对象,然后定义了一个 Builder 接口以及一个实现了该接口的 ConcreteBuilder 结构体。Director 结构体负责使用 Builder 构建产品。

通过运行上述代码,你可以看到 ConcreteBuilder 如何被用于构建产品,然后 Director 如何使用 ConcreteBuilder 构建产品,并最终使用构建好的产品。