聊到 golang 的设计模式,我特别想把丑话说在前面:如果你是从 Java 或者 C++ 转过来的,带着二十多年前 GoF 那本经典目录逐条对照,大概率会踩一脚泥。Go 没有继承,没有传统意义上的类,接口还是隐式实现的,很多经典模式在 Go 里要么别扭,要么根本不需要。但这不代表设计模式在 Go 里没有意义。我这些年实际写 Go 项目,确实用 Go 重新诠释过单例、工厂、装饰器、观察者、责任链,也发现了一些 Go 生态里特有、GoF 完全没提过的"玩法"。
这篇内容不打算做理论复读,就聊聊我在生产代码里真正用过、踩过坑、后来沉淀下来的模式与例子。适合准备 Go 面试、正在啃开源项目源码,或者刚从其他语言转 Go、想建立自己模式库的读者。我会尽量把"为什么这样写"讲清楚,而不是只丢代码。
1. 设计模式在 Go 里的处境:先泼一盆冷水
1.1 Go 的语法土壤,改写了模式的"底层逻辑"
Java 和 C++ 的设计模式,本质上是用来弥补语言表达力不足的。比如"继承复用"会带来强耦合,所以有人搞出"组合优先于继承";因为接口需要显式实现、类之间的关系是编译期确定的,所以适配器、装饰器这些结构型模式才有用武之地。
Go 的答案不一样。它没有继承,但有结构体嵌入;接口不需要关键字去"实现",只要某个类型的方法集合匹配,它就天然满足了接口。这意味着很多模式在 Go 里会自动简化。
举个例子。Java 里的单例模式要写私有构造函数、静态 volatile 字段、双重检查锁,有的还担心反射破坏。Go 里一个sync.Once就能干净解决。类的继承树没了,模式里大量围绕层级关系的类的部分就失去了存在意义;接口变成了鸭子类型式约束,很多结构性变化不再需要显式的包装类,只需要让函数签名接受一个接口即可。
这也是很多老 Gopher 说"Go 不需要设计模式"的真实原因。不是否定模式的思考方式,而是说 Go 自带了一批惯用法(idioms),已经把经典模式的能力内化了。真正的价值不在于照搬某个模式的名字和类图,而在于"解耦、复用、扩展、控制反转"这些底层意图在 Go 里怎么表达。
1.2 哪些模式在水土不服,哪些在 Go 里反而更顺手
我把经典模式大体分了三类:别扭的、可以改造的、还有被 Go 惯用法"接管"的。
| 经典模式 | 在 Go 里的常见对应物 | 我的评价 |
|---|---|---|
| 单例 | init()+ 包级变量 /sync.Once | 依旧常用,但要留意全局状态问题 |
| 工厂方法 | NewXxx(...)构造函数 | 直接内化成了 Go 的命名习惯 |
| 抽象工厂 | 构造函数入参传接口 | 很少单独写,过度设计的重灾区 |
| 适配器 | 隐式接口实现,io.Writer等系统接口 | 大量内置于标准库,写起来非常轻 |
| 装饰器 | func(http.Handler) http.Handler中间件 | 这是 Go 中间件模式的根 |
| 观察者 | channel + goroutine | 用并发原语直接改写 |
| 策略 | 函数类型或接口作为字段 | Go 里最简单、最常用 |
| 模板方法 | 函数类型的自定义回调 | 基本被函数参数取代 |
| 责任链 | 中间件切片 / handler 链 | 在 net/http 生态里随处可见 |
| 迭代器 | range、slice | 语言特性已经覆盖,基本不需要自己写 |
这张表不是让大家去逐条背,而是提供一个判断标准:某一个模式如果还需要用类和继承来支撑,那在 Go 里基本要换个思路;如果模式的核心价值是"让行为可以替换、让变化可以隔离",那 Go 里大概率有更简洁的实现方式。
2. 创建型模式在 Go 里的落地:单例、工厂与选项模式
2.1 单例模式:sync.Once是标准答案
单例是我看到从 Java 转 Go 的同学最容易携带的"习惯"。Go 里做一个线程安全的单例,最朴素的方式是包级变量 +init():
package config var DefaultConfig = loadConfig() func loadConfig() *Config { // 读环境变量、ini 文件等 return &Config{Timeout: 3 * time.Second} }init()的触发是包首次被引用时,单线程执行,天然安全。但问题在于:它不可控、不能带参数、也不能在测试时随意替换。我更常用的是sync.Once:
package config type Config struct { DBURL string LogLevel string } var ( cfg *Config cfgOnce sync.Once ) func GetConfig() *Config { cfgOnce.Do(func() { cfg = &Config{ DBURL: "mysql://localhost:3306", LogLevel: "info", } }) return cfg }这样做的理由是:除了首次初始化,后续每次调用几乎零开销;初始化逻辑可以包在一个闭包里,出错时可以 panic 或记录日志;比init()更直观。实际使用中我有一个小技巧:如果需要测试时换配置,与其全局替换,不如在包内留一个setConfigForTest(*Config)这样的非导出函数,只在测试文件里调用。这样可以保留单例语义,又给测试留了后门。
不过我对单例的态度是:能用依赖注入就用依赖注入。全局单例最隐蔽的问题是隐式依赖——调用方看到GetConfig()不知道配置从哪来,不翻实现根本不知道它还依赖环境变量。模式本身不坏,坏在把"全局"当成默认选择。
2.2 工厂方法在 Go 里变成了一种命名习惯
Go 官方从来不叫它"工厂模式",但社区早就一致性地把构造函数命名为NewXxx。比如http.NewServeMux()、log.New()、csv.NewReader()。这个模式在 Go 里的体现是:构造函数返回类型,而不是返回接口。
这里面有个经常讨论的争议:构造函数到底应该返回接口还是返回具体类型?我的建议是优先返回具体类型。原因很简单,返回接口会让你多写一个毫无意义的抽象,调用方拿到接口反而丢失了具体方法。只有在确实有多种实现、且调用方关心的只有行为时,才让工厂函数返回接口:
type Cache interface { Set(key string, value any) Get(key string) (any, bool) } func NewMemoryCache(size int) Cache { return &memoryCache{data: make(map[string]any), maxSize: size} } func NewRedisCache(addr string) Cache { return &redisCache{addr: addr} }这样写的好处是调用方只依赖Cache接口,测试时也能直接注入一个内存 fake 实现。但要注意一个分寸:如果项目里暂时只有一种实现,没必要先抽象出接口。Go 社区有一句非常实用的话——"Accept interfaces, return structs",也就是说接口要由消费者去定义,而不是生产者拍脑袋定。这个原则我后面还会再展开。
2.3 函数式选项:Go 里最有价值的创建型"模式"
GoF 里没有这玩意,但它绝对算 Go 社区自创的最成功模式。Java 里解决"构造函数参数爆炸"用的是 Builder 模式,代码量非常可观。Go 里一个函数类型就能把"可选参数"这个需求办得干干净净。
type Server struct { addr string port int timeout time.Duration maxConns int } type Option func(*Server) func WithPort(port int) Option { return func(s *Server) { s.port = port } } func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } } func WithMaxConns(n int) Option { return func(s *Server) { s.maxConns = n } } func NewServer(addr string, opts ...Option) *Server { s := &Server{ addr: addr, port: 8080, timeout: 3 * time.Second, } for _, opt := range opts { opt(s) } return s }调用时:
server := NewServer("0.0.0.0", WithPort(9090), WithTimeout(5*time.Second))这段短短的函数式选项承载了三个好处:默认值有天然的家;调用方只看参数名就能明白配置含义;新增配置项不需要改构造函数签名,不存在 Java 里的"构造器重载爆炸"。我写 SDK 和内部基础组件时,基本默认采用这种方式。
有一个值得注意的点:options 一定要在构造函数里先赋完默认值再循环应用。如果不小心在NewServer里先apply再初始化,默认值会把用户传入的值覆盖掉,这种 bug 非常隐蔽。我自己的习惯是函数体开头一行s := &Server{...},写完默认值后马上for循环,任何选项逻辑都不要插在这两段中间。
3. 结构型模式:用接口和组合代替继承
3.1 适配器模式在 Go 里是"隐式"的
Java 里的适配器,通常要写一个类去实现目标接口,并在内部转发给被适配的对象。Go 里接口是隐式满足的,只要方法签名对上,类型就能被当作接口用,所以适配器逻辑被大幅简化。
最常见的例子是io.Writer。标准库到处都在消费io.Writer,如果你有一个自定义日志类型,想把它接到某个需要io.Writer的函数里,根本不用继承什么类,直接实现Write方法就行:
type AuditWriter struct { mu sync.Mutex lines []string } func (a *AuditWriter) Write(p []byte) (n int, err error) { a.mu.Lock() a.lines = append(a.lines, string(p)) a.mu.Unlock() return len(p), nil }然后这个*AuditWriter就能直接传给http.ResponseWriter、log.SetOutput等任何需要io.Writer的位置。你不需要知道调用方是谁,也不需要显式声明"我实现了哪个接口"。
这个机制给适配器模式带来的实际变化是:适配器不再是"包了一层的类",而是一个独立的方法集。它天然解耦,因为你写代码时根本不关心接口的定义在哪里。这也是 Go 开发者喜欢定义小接口的原因——接口越小,被隐式满足的概率越大,复用价值越高。
3.2 装饰器模式:middleware 是 Go 世界的头号实践
装饰器的精神是"在不改变原结构的前提下,给对象动态添加行为"。Java 里要维护抽象装饰者类、具体装饰者类,层级很深。Go 里用函数类型一行就能定义装饰器:
type Middleware func(http.Handler) http.Handler这个函数接收一个http.Handler,返回一个新的http.Handler,内部可以在调用前后加入行为。比如最简单的日志中间件:
func WithLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s %s elapsed=%s", r.Method, r.URL.Path, time.Since(start)) }) }多个中间件的组合可以封装成一个链:
func Chain(handler http.Handler, mws ...Middleware) http.Handler { for i := len(mws) - 1; i >= 0; i-- { handler = mws[i](handler) } return handler }我实际使用中遇到过一个非常典型的坑:中间件的执行顺序。第一直觉是把中间件按顺序从头套到尾,结果日志记录的时间全不对。原因很简单——Chain里的循环是倒着 wrap 的,最后一个中间件成了最外层的执行者。如果你希望"请求先过日志,再过鉴权",那就按"日志、鉴权"的次序传入,而循环里的索引倒序会让最先传入的日志成为最外层包装,也就是最先执行。理解这一点后,几乎所有 middleware 相关问题都能解决了。
net/http生态里的 gin、chi、echo 的中间件本质都是这个套路,学会一个,其他都通透。
3.3 结构体嵌入:Go 对"组合优于继承"的实体化
经典设计模式书里反复强调"多用组合,少用继承",Go 直接把组合做进了语言层面。嵌入不是继承,但它允许一个结构体直接使用另一个结构体的字段和方法。
type Base struct { ID int64 CreatedAt time.Time } type User struct { Base Name string Email string }User可以直接用u.ID、u.CreatedAt,也可以通过u.Base.ID访问。这种写法在领域模型里很常用,比如把公共字段抽成一个基础结构体,嵌入到多个子结构体里。
要注意一个常见误用:把嵌入当成继承来用,试图覆写被嵌入类型的方法。Go 的嵌入在方法提升上是"升维"而非"多态",一旦你给外层定义了同名方法,内层的方法就被遮蔽了,这跟 Java 的@Override是两回事。如果真需要多态行为,应该考虑接口、函数字段,或者组合内嵌接口:
type Repository struct { database interface { Insert(ctx context.Context, obj any) error } }这种"结构体组合"的方式比继承更利于测试,因为你可以替换内部组件而不改变结构体本身。
4. 行为型模式:策略、观察者与责任链的 Go 化
4.1 策略模式:在 Go 里简单到不像模式
策略模式的核心是"把算法封装成可替换的对象,在运行时选择"。Go 里最直接的写法就是函数字段:
type PriceCalculator struct { discount Func(price float64) float64 } func percentDiscount(percent float64) Func(price float64) float64 { return func(price float64) float64 { return price * (1 - percent/100) } } func fixedCut(price float64) float64 { if price > 100 { return price - 30 } return price }需要状态、或者一组方法需要配合时,再用接口:
type Notifier interface { Send(ctx context.Context, target string, msg []byte) error } type EmailNotifier struct{...} type SMSNotifier struct{...}这样的好处是显而易见的:调用方不需要关心内部的具体实现,只需要面向Notifier编程。但我一定要提醒一句,接口抽象只有在你确实面临"多种实现"的选择时才有价值。如果现在只有一个EmailNotifier,你先写出接口,那不仅是浪费,还可能因为接口设计不合理而被迫反复调整。我的个人经验是:先写两个不同实现,总结出共同行为,再抽接口。从第二个实现冒出的时候开始抽象,通常能得到更合理的边界。
4.2 观察者模式:用 channel 重写事件分发
Java 的观察者模式要定义主题接口、观察者接口、注册与通知逻辑,代码很重。Go 里用 channel + goroutine 可以做出同样效果,而且并发语义更清晰。我写过一个简化的事件总线:
type Event struct { Topic string Data any } type EventBus struct { mu sync.RWMutex subscribers map[string][]chan Event } func (b *EventBus) Subscribe(topic string) <-chan Event { ch := make(chan Event, 1) b.mu.Lock() b.subscribers[topic] = append(b.subscribers[topic], ch) b.mu.Unlock() return ch } func (b *EventBus) Publish(topic string, data any) { ev := Event{Topic: topic, Data: data} b.mu.RLock() channels := b.subscribers[topic] b.mu.RUnlock() for _, ch := range channels { ch <- ev } }使用方:
orderEvents := bus.Subscribe("order.created") go func() { for ev := range orderEvents { handleOrderCreated(ev) } }()这个实现里 channel 用了容量为 1 的缓冲,是个刻意的取舍:如果主发布逻辑被某个消费者阻塞,哪怕只是瞬间,也会影响整条事件链的吞吐;但容量为 0 又会导致发布者必须等消费者取走。容量 1 是个折中。对于高吞吐、不允许阻塞的场景,通常会把Publish里的发送移到独立 goroutine,或者直接使用非阻塞发送。注意,Go 的观察者模式不会自动处理"消费者退出后 channel 泄漏"的问题,所以在长时间运行的服务里,最好给订阅方留一个close(ch)的退出机制。
4.3 责任链模式:中间件链就是最标准的责任链
责任链的关键点在于:每个处理者都有机会处理请求,或者把它传给下一个。Go 的中间件链除了能装饰,也是一种责任链——它天然支持"某个环节直接返回,不再继续往下传"。
func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) }当RequireAuth判定无权访问时,直接终止链;有权时调用next.ServeHTTP进入下一环。这正是责任链模式的行为。如果要在 Go 里手动实现一个非 http 领域的责任链,最优雅的写法依然是保留Handler的抽象:
type Handler interface { Handle(ctx context.Context, req *Request) *Response } type HandlerFunc func(ctx context.Context, req *Request) *Response func (f HandlerFunc) Handle(ctx context.Context, req *Request) *Response { return f(ctx, req) }把"节点"定义成函数类型,链的组装就是函数列表的遍历。这是 Go 里处理责任链问题的通用骨架。
5. 并发领域里的"设计模式":Go 自己的独门套路
5.1 Pipeline 模式:用 channel 串联阶段
Go 并发模型最著名的口号是:"不要通过共享内存来通信,而要通过通信来共享内存。"Pipeline 就是把这句话落地的模式:每个阶段是一个函数,输入一个 channel,输出一个 channel,数据在这些阶段之间流动。
func generator(nums ...int) <-chan int { out := make(chan int) go func() { for _, n := range nums { out <- n } close(out) }() return out } func square(in <-chan int) <-chan int { out := make(chan int) go func() { for n := range in { out <- n * n } close(out) }() return out }使用:
for result := range square(generator(1, 2, 3, 4)) { fmt.Println(result) }Pipeline 最重要的语义是"通道由生产者关闭"。上面的square只负责从in读取,它关闭的是自己的输出out,而不是上游的in。如果多个 goroutine 共用一个输出 channel,关闭的动作只能做一次,这时要确保所有生产者都结束再close,否则会触发向已关闭 channel 发送数据的 panic。
实际项目里 Pipeline 通常是多阶段并发执行的,每个阶段的 goroutine 可以互相独立消费,这就是天然的并发流水线。我常用它来处理批处理任务,比如"读文件 -> 解析 -> 清洗 -> 入库存"这种四段处理,每段时间不同,但各阶段之间通过 channel 解耦,吞吐明显比串行高。
5.2 Worker Pool:控制并发的基本格式
Worker Pool 不算 GoF 模式,但在 Go 工程里的出现频率远超很多经典模式。它的核心思想是固定数量 goroutine 去消费一个任务 channel。
const workerCount = 4 func worker(id int, jobs <-chan int, wg *sync.WaitGroup) { defer wg.Done() for j := range jobs { process(j) // 模拟耗时处理 } } func main() { jobs := make(chan int, 100) var wg sync.WaitGroup for i := 0; i < workerCount; i++ { wg.Add(1) go worker(i, jobs, &wg) } for j := 0; j < 1000; j++ { jobs <- j } close(jobs) wg.Wait() }使用上有三个必须记住的要点:第一,close(jobs)要在所有任务发送完之后执行,worker 才能正常退出range循环;第二,sync.WaitGroup增加计数器要在启动 goroutine 之前,否则可能出现 Wait 已经退出、goroutine 还在跑的情况;第三,如果 worker 内部会产生错误,要么用errgroup直接收敛到第一个错误,要么把错误通过另一个 channel 送回去,不要在主 goroutine 里打印日志就完事。
我在实际项目里看到最多的问题,是把 worker 数量无脑开到 CPU 核数的几十倍。如果任务本身是纯计算,这个方案毫无收益;如果任务是 IO 密集型,worker 数量通常要结合超时和背压机制综合考虑。Channel 有缓冲没关系,但一旦缓冲被填满,生产者的发送就会阻塞,这其实是一种天然的背压保护。
5.3 Context 模式:Go 特有的跨层取消传播
Context 是标准库提供的取消与超时机制,但它更像一种"模式":让一个请求生命周期内的所有 goroutine 共享取消信号。
ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() select { case result := <-slowOperation(ctx): fmt.Println(result) case <-ctx.Done(): err := ctx.Err() fmt.Println("操作被取消:", err) }slowOperation内部也在监听ctx.Done(),所以一个超时信号可以从最外层一路穿透到最深层,所有协程同时收手。这种跨层传播能力,在设计模式里没有对应物。如果你要让一个请求的取消状态影响 DB 操作、外部 HTTP 调用、消息队列消费,context就是唯一标准化的方式。
我的原则是:所有函数,只要是会被并发调用的、或者需要被取消的,都尽量传ctx作为第一个参数。这不是教条,而是为了让"取消逻辑"成为一个统一的约定。看过太多代码,上层取消了但下层还傻等到超时,整个请求憋了三个超时才返回,定位问题极其痛苦。
6. 实战中我踩过的"模式坑":什么时候别套模式
6.1 为了模式而模式:当抽象失去了成本合理性
我见过最离谱的 Go 代码,是给只有一个实现的数据源写了三层抽象:Repository接口、抽象工厂、创建出唯一的实现类,再加上一个全局单例管理器。结果添加一个字段要动四个文件。
这就是典型的反向模式:用模式不是为了解决问题,而是为了"显得专业"。Go 社区对这种行为的容忍度很低,因为 Go 的哲学就是直白、简单、不绕弯。如果某个模式需要引入大量间接层,却没有换来测试性、复用性或扩展性的实际提升,那它就是负担。
判断是否该上抽象,我有个非常朴素的标准:如果有人需要同时在两个实现之间切换,或者你需要 mock 它来测试上层逻辑,再考虑抽象。否则,直接依赖具体类型,等需求来了再重构。大多数情况下,接口晚一点抽,抽出来反而更准确。
6.2 接口越抽象越难维护:Go 崇尚小接口
Go 社区流传一句话:接口越大,接口的抽象能力越弱。标准库的io.Reader只有Read一个方法,io.Writer只有Write一个方法,这就是小接口的极致。Client 端消费的时候,可以按需把大接口裁剪成小接口来使用:
func save(ctx context.Context, w io.Writer, data []byte) error { _, err := w.Write(data) return err }调用方可以传入任何实现了Write的东西,哪怕它还有一百个其他方法,都没关系。反过来,如果你定义了一个带 10 个方法的接口,任何实现都要凑齐 10 个方法,这个接口几乎不可能被复用。我在写代码时给自己定了个规矩:接口方法数量超过 3 个,就要停下来想是不是设计出了问题。不是说一定错,但大概率是抽象得过头了。
另外还有一条很实用的约定:在自己的模块里定义接口,而不是在依赖方模块里定义。换句话说,你消费第三方库时,可以在自己的包里定义"我只需要的能力",第三方库的实现只要满足它即可。这样你的代码不依赖具体库类型,测试和替换都方便。
6.3 我对设计模式在 Go 里的最终态度:把模式当词汇,而不是蓝图
写了这么多年 Go,我的心态已经从"套模式"转变成了"借概念"。设计模式真正的贡献,是给了开发者一套共享词汇:你一说"策略模式",别人就知道是行为可替换;你一说"middleware 责任链",别人就知道是顺序包装处理。这些词汇让沟通变得高效,但它们不是施工图纸。
回到 Go 的语境里,我最终沉淀下来的习惯其实非常简单:
- 创建东西用
NewXxx构造函数,参数多就加函数式选项; - 单例只在"资源获取成本高且生命周期同进程"时才用,且用
sync.Once; - 行为替换优先用函数字段,复杂了再用接口;
- 跨 goroutine 解耦优先用 channel;
- 取消和超时把
context当第一参数传下去; - 所有抽象都以"是否真正需要第二种实现"为准。
最后再分享一个我实测有效的学习方法:与其照着 GoF 目录逐条实现,不如去读标准库和知名开源项目里高频出现的代码,比如io、net/http、context、sync。你会发现很多经典模式被 Go 用绝对朴素的方式重构了,而这些重构恰好就是"golang 中常用的设计模式"最真实的答案。如果你能把http.Handler的中间件链和sync.Once的单例理解透彻,面试和工作中的大部分场景就已经覆盖了。