写过一段时间Go的人都会有类似的感受:Go没有类、没有继承,甚至连构造函数都没有,那Java里那一大堆设计模式还怎么写?答案是设计模式的核心从来不依赖语言特性,它依赖的是对变化点的封装和对扩展性的取舍。Go用组合、接口和goroutine,把这些模式实现得比传统OOP语言更轻巧。这篇文章挑出实战中出场率最高的几种模式,逐一拆解原理并给出完整代码,看完可以直接搬到自己的项目里用。

单例模式:sync.Once是唯一正确答案
单例模式要解决的问题是:某个对象在整个进程中只应该初始化一次,比如数据库连接池、全局配置、日志句柄。在Java里要纠结双重检查锁、volatile关键字,而在Go里标准库直接给出了sync.Once,它内部用原子操作和互斥锁保证了并发安全和只执行一次的语义,代码量几乎为零。
package main
import (
"fmt"
"sync"
)
type Config struct {
Values map[string]string
}
var (
instance *Config
once sync.Once
)
// GetConfig 返回全局唯一的配置实例
func GetConfig() *Config {
once.Do(func() {
fmt.Println("初始化配置,只会执行一次")
instance = &Config{
Values: map[string]string{
"env": "production",
"port": "8080",
}
}
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
cfg := GetConfig()
fmt.Printf("goroutine %d 拿到的实例地址: %p\n", n, cfg)
}(i)
}
wg.Wait()
}
这段代码即使开一百个goroutine并发调用GetConfig,初始化逻辑也只会执行一次,所有调用者拿到的是同一个指针。有一点需要特别注意:如果使用懒汉式的手写锁版本,很容易在判断和赋值之间出现竞态,必须用go run -race验证。而饿汉式(包级别变量直接初始化)虽然简单,缺点是包被导入就执行初始化,哪怕这个单例从头到尾没被用到,可能白白浪费资源或拖慢启动速度。
另外Go 1.21之后标准库新增了sync.OnceFunc,可以直接把一个函数包装成只执行一次的版本,适合无状态的场景,代码还能再省两行。单例模式也有被滥用的风险,全局可变状态会让测试难以隔离,如果只是想在依赖注入框架里共享实例,优先考虑显式传参,单例留给那些真正全局唯一且初始化昂贵的资源。
策略模式:函数是一等公民,接口是退路
策略模式的本质是把一段可替换的算法逻辑抽象出来,调用方在运行时决定用哪个。在Java里要定义策略接口、写若干实现类、再配一个工厂,而在Go里,因为函数本身就是一等公民,一个函数类型就够用了。以常见的优惠计算为例:
package main
import "fmt"
// DiscountStrategy 定义为函数类型
type DiscountStrategy func(amount float64) float64
// 具体策略
func FullReduction(amount float64) float64 {
if amount >= 200 {
return amount - 50
}
return amount
}
func PercentageDiscount(amount float64) float64 {
return amount * 0.9
}
// 上下文持有策略
type Order struct {
Amount float64
Strategy DiscountStrategy
}
func (o Order) FinalPrice() float64 {
return o.Strategy(o.Amount)
}
func main() {
orders := []Order{
{Amount: 260, Strategy: FullReduction},
{Amount: 180, Strategy: PercentageDiscount},
}
for _, o := range orders {
fmt.Printf("原价 %.2f,应付 %.2f\n", o.Amount, o.FinalPrice())
}
}
可以看到,定义DiscountStrategy这个函数类型之后,任何签名匹配的函数都能直接当策略传入,不需要任何额外的类层次结构。当策略需要携带状态或者多个方法时,再升级成接口方案:定义一个包含Apply方法的接口,让具体策略作为结构体实现它。两种写法的选择标准很简单,无状态用函数,有状态用接口。
策略模式在实战中的典型应用还包括:HTTP中间件里的限流算法替换、任务队列的重试退避策略(固定间隔、指数退避、抖动)、数据导出时选择不同的序列化格式。它的好处是把if-else分支干掉了,新增策略不改老代码,符合开闭原则;代价是策略数量膨胀后需要一份注册表来管理,否则调用方会不知道有哪些策略可用,这时可以配合一个map加枚举字符串做注册中心。
生产者消费者模式:channel才是主角
如果说前两种模式在Go里只是换了个写法,那生产者消费者模式在Go里就是彻底的原生化。其他语言要用线程安全的队列加条件变量小心翼翼地实现,Go直接用带缓冲的channel表达,生产者往里丢数据,消费者从中取数据,缓冲区满了自动阻塞生产者,空了自动阻塞消费者,所有同步细节都被语言运行时接管了。
package main
import (
"fmt"
"sync"
"time"
)
func producer(ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 10; i++ {
ch <- i
fmt.Printf("生产者产出: %d\n", i)
time.Sleep(50 * time.Millisecond)
}
}
func consumer(id int, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for num := range ch {
fmt.Printf("消费者%d处理: %d\n", id, num)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
ch := make(chan int, 5)
var pwg, cwg sync.WaitGroup
pwg.Add(1)
go producer(ch, &pwg)
for i := 1; i <= 2; i++ {
cwg.Add(1)
go consumer(i, ch, &cwg)
}
pwg.Wait()
close(ch) // 生产结束后关闭channel,消费者的range会自动退出
cwg.Wait()
fmt.Println("全部处理完毕")
}
这段代码里有两个关键细节。第一是缓冲区大小设为5,它起到了削峰填谷的作用,生产速度偶尔超过消费速度时不会丢数据;第二是close(ch)的位置,必须由生产者一方在确认不再发送后关闭,消费者用range读取,channel关闭且缓冲取空后循环自动结束。向已关闭的channel发送数据会panic,而从一个无人生产且未关闭的channel读取会永久阻塞,这两个坑几乎每个Go初学者都踩过。
实战中这个模式无处不在:日志库异步刷盘、爬虫的URL调度、消息消费的批处理聚合。当需要多个消费组独立消费同一份数据时,可以配合fan-out(复制到多个channel)和fan-in(合并多个channel)变体;当消费失败需要重试时,可以在消费者内部再加一层带超时的select,把失败消息投入重试队列,形成类似延迟队列的结构。这套思路写熟练后,大部分并发编排问题都能优雅解决。
工厂模式与观察者模式:接口的两副面孔
工厂模式在Go里通常以简单工厂的形式出现,返回值是接口类型而不是具体结构体,这是Go惯用法的精髓:依赖抽象而非实现。比如一个支持多种存储后端的场景:
package main
import "fmt"
type Storage interface {
Save(key string, data []byte) error
}
type MemoryStorage struct{ store map[string][]byte }
func (m *MemoryStorage) Save(key string, data []byte) error {
m.store[key] = data
return nil
}
type DiskStorage struct{ path string }
func (d *DiskStorage) Save(key string, data []byte) error {
fmt.Printf("写入文件 %s/%s\n", d.path, key)
return nil
}
// 简单工厂:根据类型返回接口
func NewStorage(kind string) (Storage, error) {
switch kind {
case "memory":
return &MemoryStorage{store: make(map[string][]byte)}, nil
case "disk":
return &DiskStorage{path: "/var/data"}, nil
default:
return nil, fmt.Errorf("不支持的存储类型: %s", kind)
}
}
func main() {
s, _ := NewStorage("memory")
_ = s.Save("user:1", []byte("hello"))
}
调用方拿到的永远是Storage接口,底层换成Redis还是对象存储都不影响业务代码。Go社区不太提倡为工厂再套抽象工厂层,一个函数加switch已经足够,过度设计反而违背了Go追求简单的哲学。如果分支真的多到难以维护,再用map注册表把字符串映射到构造函数即可。
观察者模式则用于一对多的通知场景,比如订单状态变化后要同时通知库存、短信、积分多个模块。Go里可以用回调列表实现,也可以直接用channel广播。用回调的实现方式是主体维护一个[]func(event)切片,事件发生时遍历调用,注意遍历时要对切片加读锁或复制副本,避免并发注册回调引发竞态。用channel的实现则是每个观察者持有自己的channel,主体事件到来时逐个非阻塞发送。两种方式的选择看观察者是否长期驻留:短期一次性通知用回调更轻,长期订阅关系用channel配合context做取消传播更稳。无论哪种,都别忘了一个原则——通知逻辑出错不应该阻塞主体流程,把回调包在func(){ defer recover }()里或者把channel发送放进select加default,是保命的惯用手段。
写在最后
Go语言的设计哲学是用最少的语言特性解决实际问题,它没有继承,但有组合;没有注解,但有反射和代码生成;没有内建的事件总线,但有channel。本文提到的几种模式覆盖了日常开发的大多数场景:单例管全局资源,策略管算法替换,生产者消费者管并发编排,工厂和观察者管模块解耦。真正重要的是理解每个模式背后的动机,而不是死记类图。下次写代码前先问一句:这里的变与不变分别是什么?答案往往就指向了该用的那个模式。
Golang设计模式Go语言单例模式修改时间:2026-09-07 23:40:48