真实世界的Go设计模式 - 单例模式 和 惰性初始化模式

在面向对象编程语言中,单例模式(Singleton pattern)确保一个类只有一个实例,并提供对该实例的全局访问。

那么Go语言中,单例模式确认一个类型只有一个实例,并提供对改实例的全局访问,一般就是直接访问全局变量即可。

比如Go标准库中的os.Stdinos.Stdoutos.Stderr分别代表标准输入、标准输出和标准错误输出。它们是*os.File类型的全局变量,可以在程序中直接使用:

1
2
3
4
5
var (
Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)

又比如io包下的EOF:

1
var EOF = errors.New("EOF")

Go标准库中有很多这样的单例的实现,又比如http.DefaultClienthttp.DefaultServeMuxhttp.DefaultTransportnet.IPv4zero都是单例对象。

有时候,有人也认为是单例模式也是反模式。

反模式(Anti-pattern)是一种在软件工程中常见的概念,主要指在软件设计、开发中要避免使用的模式或实践。

反模式的一些主要特征包括:

  • 它通常是初学者常犯的错误或陷阱。
  • 它反映了一种看似可行但实际上低效或错误的解决方案。
  • 使用反模式可能在短期内出现类似解决问题的效果,但长期来看会适得其反。
  • 它通常是一个坏的或劣质的设计,不符合最佳实践。
  • 存在一个更好的、可替代的解决方案。

一些常见的反模式示例:

  • 复制-粘贴编程:为了重复使用代码,直接复制粘贴,而不创建函数或模块。
  • 上帝对象:一个巨大的包含全部功能的复杂对象。
  • 依赖注入滥用:即使简单的对象也进行依赖注入,增加了复杂性。
  • 自我封装:通过封装无谓的细节来增加类的复杂性。
  • 过度抽象和设计:代码缺乏可读性

为什么这么说呢,加入两个goroutine同时使用http.DefaultClient, 其中一个goroutine修改了这个client的一些字段,也会影响到第二个goroutine的使用。

而且这些单例都是可修改对象,第三库甚至偷偷修改了这个变量的值,你都不会发现,比如你想连接本地的53端口,查询一些域名,但是可能被别人劫持到它的服务器上:

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
package main
import (
"fmt"
"net"
"github.com/miekg/dns"
)
func main() {
// 单例对象被修改,实际可能在一个第三包的init函数中写了下面这一行
net.IPv4zero = net.IPv4(8, 8, 8, 8)
// 设置DNS服务器地址
dnsServer := net.JoinHostPort(net.IPv4zero.String(), "53")
// 创建DNS客户端
c := new(dns.Client)
// 构建DNS请求消息
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn("rpcx.io"), dns.TypeA)
// 发送DNS请求消息
resp, _, err := c.Exchange(msg, dnsServer)
if err != nil {
fmt.Println("Error sending DNS request:", err)
return
}
// 解析DNS响应消息
ipAddr, err := parseDNSResponse(resp)
if err != nil {
fmt.Println("Error parsing DNS response:", err)
return
}
// 输出查询结果
fmt.Println("IPv4 Address for google.com:", ipAddr)
}
func parseDNSResponse(resp *dns.Msg) (string, error) {
if len(resp.Answer) == 0 {
return "", fmt.Errorf("No answer in DNS response")
}
for _, ans := range resp.Answer {
if a, ok := ans.(*dns.A); ok {
return a.A.String(), nil
}
}
return "", fmt.Errorf("No A record found in DNS response")
}

本来我想查询本机的dns服务器,结果却被劫持到谷歌的8.8.8.8 DNS服务器上进行查询了。

惰性初始模式(Lazy initialization, 懒汉式初始化)推迟对象的创建、数据的计算等需要耗费较多资源的操作,只有在第一次访问的时候才执行。惰性初始是一种拖延战术。在第一次需求出现以前,先延迟创建对象、计算值或其它昂贵的代码片段。

一句话,也就是延迟初始化。

如果你是Java程序员,面试的时候大概率会被问到单例的模式的实现,就像问茴香豆的茴字有几个写法。Java中大概有下面几种单例的实现:

  • 饿汉式(Eager Initialization)

  • 懒汉式(Lazy Initialization)

  • 双重检查锁(Double-Checked Locking)

  • 静态内部类(Static Inner Class)

  • 枚举单例(Enum Singleton)

后面四种都属于惰性初始模式,在实例被第一次使用才会初始化。

Rust语言中常使用lazy_static 宏来实现惰性初始模式实现单例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lazy_static! {
static ref SINGLETON: Mutex<Singleton> = Mutex::new(Singleton::new());
}
struct Singleton {
// Add fields and methods as needed
}
impl Singleton {
fn new() -> Self {
Singleton {
// Initialize fields
}
}
}

而在Go标准库中,可以使用sync.Once来实现惰性初始单例模式。比如os/user获取当前用户的时候,只需执行一次耗时的系统调用,后续就直接从第一次初始化的结果中获取,即使第一次查询失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Current() (*User, error) {
cache.Do(func() { cache.u, cache.err = current() })
if cache.err != nil {
return nil, cache.err
}
u := *cache.u // copy
return &u, nil
}
// cache of the current user
var cache struct {
sync.Once
u *User
err error
}

在即将发布的Go 1.21中,sync.Once又多了三个兄弟:

1
2
3
func OnceFunc(f func()) func()
func OnceValue(f func() T) func() T
func OnceValues(f func() (T1, T2)) func() (T1, T2)

它们是基于sync.Once实现的辅助函数,比如Current就可以使用OnceValues改写,有兴趣的同学可以试试。

这三个新函数的讲解可以阅读我先前的一篇文章:sync.Once的新扩展 (colobu.com)