Go 语言写业务写久了,总会有一个阶段觉得“代码能跑和代码能维护”之间差着一道天堑。我整理这份 23 种设计模式的合集,起因就是团队里新同学每次看到一堆接口和组合就发怵,问得最多的一句话是:这玩意儿到底解决什么问题?于是我把 Go 语言里这些模式的实现思路、关键代码、适用场景和踩过的坑全部串起来,从单例到访问者,一份速查式笔记。无论你是刚从 Java/C++ 转 Go 的老手,还是写过一段时间 CRUD 想提升架构能力的新人,这份合集都可以直接当案头参考。
1. 写在前面:设计模式在 Go 里还适用吗
很多人一听“设计模式”四个字,下意识就觉得是 Java 的专属词汇。确实,23 种经典模式是从面向对象语言的经验里总结出来的,里面大量使用继承、多态、抽象类这些概念。但 Go 语言的设计哲学恰恰相反:它没有继承,只有组合;没有类,只有结构体和接口;没有方法重载,接口的实现是隐式的。那这些模式还有没有意义?
我的答案是:模式背后的意图永远有意义,但 Go 的实现方式会长得和 Java 教程里完全不一样。比如模板方法模式,Java 用抽象类加继承来定义算法骨架,Go 里通常用接口加嵌入结构体来做;再比如迭代器模式,Go 的 range 关键字直接消灭了绝大多数手写迭代器的需求。真正需要学的是“遇到某种结构问题,应该用什么样的协作方式去拆解”,而不是死记 UML 图。
这份合集采用分类速查的思路来组织:创建型模式解决“对象怎么来”,结构型模式解决“对象怎么组合”,行为型模式解决“对象怎么协作”。每个模式我都尽量给出一个最小可运行的示例代码,然后重点说为什么在 Go 里要这么写,以及哪些地方容易翻车。
1.1 为什么要把 23 个模式全部过一遍
我见过不少同事写 Go 代码,接口滥用和接口不用是两种极端。有人一个函数都要搞三个层级的接口,有人把所有逻辑堆在一个几百行的函数里。把 23 个模式系统地过一遍,最大的收获其实是建立一种“问题词典”:看到对象创建的重复代码,知道单例和工厂在等什么;看到中间件嵌套,知道责任链长什么样;看到策略频繁切换,知道怎么用函数作为一等公民来替代对象结构。
这份合集的价值在于:不是让你把所有模式都硬塞进项目里,而是让你在看到一段代码时,能识别出它属于哪类问题、有没有更简洁的 Go 式解法。我自己在 review 代码时,判断依据通常就是“这个模式是帮我们减少了重复,还是增加了不必要的间接层”。
1.2 阅读这份合集的方法
合集很长,我不建议一次性从头看到尾。我的建议是:先看第 2 和第 3 部分,把创建型、结构型里面最常用的十来个模式过一遍;第 4、第 5 部分的行为型模式里,命令、观察者、责任链、策略这几种在实际项目中出现频率最高,可以优先看;解释器、访问者这类比较冷门的,了解思想即可。每个小节都有代码片段,你完全可以照着敲一遍,再回头看“注意事项”,这样印象会深刻很多。
另外要强调一点:代码示例里我会刻意使用最简写法,省略错误处理和并发细节,聚焦模式本身。实际工程里你需要考虑的东西远不止这些,我会在每一节末尾单独提醒。
2. 创建型模式:对象怎么来
创建型模式管的是一件事:怎么让对象的创建过程更灵活、更能应对变化。Go 语言里没有构造函数的概念,习惯上用一个名为NewXxx的普通函数来创建对象,这本身就是工厂方法的雏形。下面把这 5 个模式逐个拆开。
2.1 单例模式:sync.Once 一把梭
单例模式保证某个类型全局只有一个实例,并提供一个统一的访问入口。Go 里的标准解法几乎只有一个:sync.Once。
package singleton import "sync" type Config struct { Host string Port int } var ( instance *Config once sync.Once ) func GetConfig() *Config { once.Do(func() { instance = &Config{ Host: "127.0.0.1", Port: 8080, } }) return instance }我见过不少人用sync.Mutex加双重检查来实现单例,其实完全没必要,sync.Once内部已经处理好了并发安全和只执行一次的语义。另外,sync.Once还有一个容易踩的坑:如果once.Do传入的函数执行一半发生 panic,整个程序会直接挂掉,并且once不会重新执行。所以初始化函数里尽量不要做可能失败的 IO 操作,或者把错误存到实例字段里让调用方检查,而不是直接 panic。
还有一个偏实战的建议:大部分单例对象其实是配置、连接池这种“全局共享但内容不可变”的东西,这种情况我更喜欢在init函数里直接初始化,连sync.Once都不用。只有在“惰性初始化、且初始化开销很大”时才需要单例模式。
2.2 工厂方法模式:构造函数也是接口
工厂方法模式解决的是“根据条件创建不同实现”的问题。Go 里没有抽象构造器,但接口加函数就能轻松表达。最常见的例子是日志系统:根据配置创建文件日志、控制台日志还是远程日志。
package factory type Logger interface { Log(msg string) } type FileLogger struct{} func (FileLogger) Log(msg string) { /* 写入文件 */ } type ConsoleLogger struct{} func (ConsoleLogger) Log(msg string) { /* 输出控制台 */ } func NewLogger(typ string) Logger { switch typ { case "file": return FileLogger{} case "console": return ConsoleLogger{} default: return ConsoleLogger{} } }注意一点:工厂方法返回的接口越简单越好。如果返回的接口里定义了七八个方法,调用方为了测试还得 mock 一堆行为,这就不值得了。Go 社区更常见的做法是让构造函数直接返回具体类型,只在确实需要多态替换时才返回接口。我写业务代码时的判断准则是:有没有第二个实现?如果没有,那就别上工厂。
2.3 抽象工厂模式:产品族之间的匹配
抽象工厂解决的是“一系列相关对象必须配套使用”的问题。说白了就是:数据库换了,连接、查询、事务这些组件要跟着一起换,不能混搭。Go 里用嵌套接口来表达“产品族”很自然。
package abstractfactory type DBFactory interface { CreateConnection() Connection CreateQuery() Query } type MySQLFactory struct{} func (MySQLFactory) CreateConnection() Connection { return MySQLConnection{} } func (MySQLFactory) CreateQuery() Query { return MySQLQuery{} } type PostgreSQLFactory struct{} func (PostgreSQLFactory) CreateConnection() Connection { return PGConnection{} } func (PostgreSQLFactory) CreateQuery() Query { return PGQuery{} }这个模式在实际业务里要谨慎使用,因为它带来的抽象层级往往比较重。我建议只在底层基础设施层面使用,比如数据库访问层、消息队列封装、跨平台文件系统这类位置。业务层如果也搞一堆抽象工厂,代码会变得非常难追。Go 的接口组合机制让抽象工厂写起来比 Java 清爽,但“需要用它”的信号依然很弱,不要为了设计而设计。
2.4 建造者模式:链式调用和 Option 模式
建造者模式用于“对象构造步骤复杂,参数特别多”的场景。Go 里有两个经典变体:一个是结构体建造者,链式方法设置字段;另一个是更符合 Go 习惯的 functional options 模式。
package builder type ServerConfig struct { Addr string Timeout int MaxConn int } type Option func(*ServerConfig) func WithTimeout(t int) Option { return func(c *ServerConfig) { c.Timeout = t } } func WithMaxConn(n int) Option { return func(c *ServerConfig) { c.MaxConn = n } } func NewServerConfig(opts ...Option) *ServerConfig { cfg := &ServerConfig{ Addr: ":8080", Timeout: 3, MaxConn: 100, } for _, opt := range opts { opt(cfg) } return cfg }这是我在日常项目中用得最多的创建型模式,因为它的扩展性极佳:新增一个配置项,只需要写一个新的 Option 函数,不会破坏已有调用方。唯一要注意的是默认值逻辑要清晰,不要出现“默认值本身也是非法的”这种坑。还有一个常见问题:Option 函数里直接修改字段时,如果配置项之间互相依赖,容易产生顺序敏感。我的建议是配置项保持独立,不要在一个 Option 里同时改多个字段。
2.5 原型模式:深拷贝才是重点
原型模式的核心是“通过复制现有对象来创建新对象”。Go 没有内置 clone 方法,所以通常归结为一个问题:怎么实现深拷贝。
package prototype type Report struct { Title string Data []map[string]int } func (r *Report) Clone() *Report { cp := *r cp.Data = make([]map[string]int, len(r.Data)) for i := range r.Data { m := make(map[string]int, len(r.Data[i])) for k, v := range r.Data[i] { m[k] = v } cp.Data[i] = m } return &cp }这个模式的坑集中在浅拷贝上。很多人以为cp := *r就完事了,结果多个对象共享同一个切片底层数组,改一个全变。如果结构体里还有指针、map、channel、函数字段,深浅拷贝的处理方式各不相同。我的经验是:能用值类型尽量用值类型;确实需要原型复制的对象,务必把引用类型的字段都单独拷贝一遍。另外也可以考虑用反射库做通用深拷贝,但性能差不少,且遇到未导出字段会出错,一般只在配置对象这种低频场景使用。
3. 结构型模式:类和对象怎么组合
结构型模式关注的是“怎么把已有的类或对象组织起来,形成更大的结构”。Go 的组合思想本身就非常契合这部分模式,很多时候一个嵌套结构体就能解决 Java 里一堆继承体系才能解决的问题。
3.1 适配器模式:让不兼容的接口对话
适配器模式的场景很典型:一个客户端依赖某个接口,但实际拿到的对象是另一个接口,中间需要一层转换。
package adapter type OldInterface interface { Send(data []byte) error } type Adapter struct { NewService *NewService } func (a Adapter) Send(data []byte) error { return a.NewService.Push(string(data)) }在 Go 的标准库里,适配器无处不在。最典型的就是io.Reader和io.Writer:一个类型只要实现了Read(p []byte)就能被各种工具函数消费。也就是说,Go 通过隐式接口实现把适配器模式集成进了语言设计里。
实际编码时,适配器最常见的写法是包装一个包级别类型的外部依赖,把它转换成项目内部定义的接口。我踩过的坑是:适配器里经常需要做数据格式转换,转换前一定要想清楚是“适配”还是“污染”。如果转换涉及业务规则,那叫映射层,不应该叫适配器;适配器只做纯粹的协议转换,不掺入业务判断。
3.2 桥接模式:抽象和实现分离
桥接模式把“抽象部分”和“实现部分”分开,让它们可以独立变化。这个模式在 Go 里的表现形式是:一个高层接口里聚合一个底层接口,而不是让单个类型同时承担两个维度的变化。
举个例子,一个消息发送系统:消息类型有文本、图片,发送渠道有邮件、短信。如果搞成四个类(文本邮件、图片邮件、文本短信、图片短信),那就是典型的类爆炸。用桥接模式,只需要两个接口维度。
package bridge type Sender interface { Send(content string) } type TextMessage struct { Sender Sender } func (t TextMessage) Deliver() { t.Sender.Send("text: hello") } type EmailSender struct{} func (EmailSender) Send(content string) { // 发送邮件 }桥接模式在 Go 项目里出现频率不高,因为接口组合太方便,很多时候你自然就写成了桥接的样子。我提醒一点:如果两维变化中有一维基本不变化,那不需要桥接,直接组合进具体类型即可。只有当“消息类型”和“发送渠道”都可能扩展时才值得引入。
3.3 组合模式:树形结构天然适配
组合模式的核心是“部分-整体”的树形结构,不管叶子还是容器,调用方式都一样。文件系统是最经典的例子。Go 里用接口加递归遍历就能实现。
package composite type Node interface { Size() int } type File struct { size int } func (f File) Size() int { return f.size } type Dir struct { children []Node } func (d Dir) Size() int { total := 0 for _, child := range d.children { total += child.Size() } return total }这个模式的优点是调用方完全不用关心节点是叶子还是目录。我在设计配置树、权限树、菜单结构时经常用到。需要注意的点是:Dir的children如果用指针存储,要避免循环引用导致死循环遍历;实现时最好给Dir增加Add方法而不是直接在外部改切片,至少保证树结构在语义上是完备的。
3.4 装饰器模式:函数式中间件
装饰器模式给对象动态添加新职责,而不是通过继承改写原类型。Go 里最流行的装饰器形态就是 HTTP 中间件。
package decorator type Handler func(req *Request) *Response func WithLogging(next Handler) Handler { return func(req *Request) *Response { start := time.Now() resp := next(req) log.Printf("cost=%v", time.Since(start)) return resp } } func WithRecovery(next Handler) Handler { return func(req *Request) *Response { defer func() { if r := recover(); r != nil { log.Printf("panic: %v", r) } }() return next(req) } }看到没有,装饰器在 Go 里甚至可以不用结构体,直接用函数包装函数。唯一需要留心的是装饰器链的顺序:外层装饰器先执行,所以日志、限流、鉴权这些不同层级的职责顺序极其重要。我刚开始写中间件时经常搞混顺序,后来统一记住一个规则:离next越远的代码越早执行。如果你需要“后处理”逻辑,记得要把代码写在next调用之后。
3.5 外观模式:给复杂系统一个简单门面
外观模式隐藏内部的复杂交互,对外提供一个简洁的接口。对于许多业务系统而言,一个 Service 层就可能扮演外观角色:外部调用方不需要知道内部有数据库、缓存、消息队列,只需要调一个方法。
package facade type PaymentFacade struct { account *AccountSystem risk *RiskSystem notify *NotifySystem } func (p PaymentFacade) Pay(userID int64, amount int) error { if err := p.risk.Check(userID); err != nil { return err } if err := p.account.Deduct(userID, amount); err != nil { return err } p.notify.Send(userID, "支付成功") return nil }外观模式是降低系统耦合度最直接的手段。但要注意:外观层最容易变成“上帝对象”,什么逻辑都往里塞。我建议外观层只做流程编排,不写具体业务规则;内部的每一个子系统对外部保持未知。这样后续替换内部实现时,调用方完全不受影响。
3.6 享元模式:共享不变的部分
享元模式通过共享大量细粒度对象来节省内存,核心是“把对象拆成内在状态(共享不变)和外在状态(随环境变化)”。一个典型例子是游戏里的植被渲染,所有草叶共享同一个模型和纹理,只有位置坐标不同。
Go 里常见的享元应用是连接池、字节缓冲池sync.Pool、以及字符串共享。sync.Pool是很地道的 Go 式享元:对象用完放回池子里,下次取出来复用,减少内存分配。
var bufPool = sync.Pool{ New: func() any { return make([]byte, 0, 4096) }, } func process() { buf := bufPool.Get().([]byte) defer bufPool.Put(buf[:0]) // 使用 buf }用享元模式时最怕的事:“共享的对象被意外修改”。放进池子的对象再取出来时,内容可能是脏的。所以sync.Pool的使用必须遵循“取出来先重置,归还前清空”的纪律。我还在代码里见过把 map 塞进池子导致数据串了的严重事故,后来强制规定池内对象只能是简单的值类型或切片。
3.7 代理模式:延迟加载和保护控制
代理模式给原对象提供一个替身,控制客户端对原对象的访问。常见用途:懒加载、访问控制、远程代理。Go 里代理就是实现同一个接口,内部持有被代理的实例。
package proxy type Service interface { Handle() error } type RealService struct{} func (RealService) Handle() error { // 真正干活 return nil } type Proxy struct { real *RealService cached bool } func (p *Proxy) Handle() error { if !p.cached { // 做一些请求过滤或者日志 } return p.real.Handle() }写代理模式时最容易犯的错是把代理类和被代理类放在同一个包并直接访问内部字段,失去代理的意义。正确做法是代理和被代理都只依赖接口,客户端只知道有Service,不知道背后是真实实现还是代理。实际项目里,gRPC 的客户端 stub 就是远程代理的典型例子;本地反向代理、缓存代理也都能在 HTTP 层见到。
4. 行为型模式(上):对象怎么协作
行为型模式是所有模式中最庞大的一类,关注对象之间的职责分配和交互方式。这一节先讲责任链、命令、解释器、迭代器、中介者、备忘录这六个。
4.1 责任链模式:请求一层层传下去
责任链模式把多个处理者串成一条链,请求沿着链传递,直到某个处理者接管。Go Web 框架的中间件就是责任链的工业化应用。
package chain type Handler func(ctx *Context) type Middleware func(next Handler) Handler func Chain(ms ...Middleware) Middleware { return func(next Handler) Handler { for i := len(ms) - 1; i >= 0; i-- { next = ms[i](next) } return next } }责任链的核心设计点是“谁来决定终止”。中间件链里,每个中间件可以选择调用next(ctx)继续,也可以直接返回终止请求。我写业务代码时经常用责任链处理“不同渠道的差异化逻辑”:每个渠道一个处理器节点,命中就处理,没命中就交棒给下一个。
常见误区是责任链过长导致排查困难。一条链超过五六个节点,读代码的人就要来回跳转。我的建议是:同一层级的处理逻辑才放链里,跨层级的职责用函数调用来表达,不要强行串成链。
4.2 命令模式:把操作变成对象
命令模式把请求或操作封装成一个对象,从而支持排队、记录日志、撤销重做。Go 里既可以用结构体,也可以用函数类型来表达命令。
package command type Command func() type Invoker struct { history []Command } func (i *Invoker) Execute(cmd Command) { cmd() i.history = append(i.history, cmd) } func (i *Invoker) Undo() { if len(i.history) == 0 { return } cmd := i.history[len(i.history)-1] i.history = i.history[:len(i.history)-1] // 执行反向操作,这里简化了 _ = cmd }命令模式在我负责的一个定时任务系统里派上过大用场:任务被封装成命令对象放到队列里,调度器统一执行,天然支持任务持久化。踩过的坑是命令对象里的上下文抓取问题:如果命令里捕获了外部变量,执行时外部变量可能已经变化,所以命令对象里的状态要尽量自包含。
4.3 解释器模式:搭建一个迷你 DSL
解释器模式定义一种语言的文法,并建立解释器来解释语言中的句子。听起来很高大上,其实很多场景就是“做一个小小的表达式计算器”或者“解析一条规则字符串”。
package interpreter type Expression interface { Interpret(ctx map[string]int) int } type Number struct { value int } func (n Number) Interpret(ctx map[string]int) int { return n.value } type Add struct { left, right Expression } func (a Add) Interpret(ctx map[string]int) int { return a.left.Interpret(ctx) + a.right.Interpret(ctx) }实际开发里,解释器模式很少自己实现整套词法分析和语法树,通常会用现成的表达式库,或者用go/ast来处理 Go 语言本身的语法树。真正需要解释器模式的场景是:你需要让业务人员配置一些规则,而规则字符串变化多端。此时比起写解释器,我更推荐先试试简单规则引擎加 JSON 配置,复杂度可控得多。
4.4 迭代器模式:Go 里几乎被 range 消灭了
迭代器模式用于顺序访问一个集合的元素而不暴露底层表示。Java 里的迭代器到处都是,但 Go 里for range直接支持数组、切片、map、字符串和 channel,绝大多数情况下不需要自己实现迭代器。
只有在某些场景下需要自定义迭代器,比如遍历一颗二叉树、遍历一个无限序列。Go 里有个很优雅的写法:用 channel 作为生成器。
func Generate(start, step int) <-chan int { ch := make(chan int) go func() { defer close(ch) for i := start; ; i += step { ch <- i } }() return ch }使用 channel 做迭代器最需要注意的是泄漏问题:如果调用方只取前几个元素就退出,生成器 goroutine 会一直阻塞在发送操作上,导致 goroutine 泄漏。要么设计成调用方负责关闭信号,要么生成固定数量的值就自动关闭。我倾向于只在数量有限且确定的情况下用 channel 迭代,否则还是老老实实写Next() (T, bool)方法。
4.5 中介者模式:让对象之间不直接对话
中介者模式用一个中介对象封装一组对象的交互,避免对象之间互相持有引用、形成网状依赖。聊天室就是教材级的例子,Go 里用 channel 实现通知中心非常自然。
package mediator type Mediator struct { ch chan string } func NewMediator() *Mediator { return &Mediator{ch: make(chan string, 10)} } func (m *Mediator) Send(msg string) { m.ch <- msg } func (m *Mediator) Receive() string { return <-m.ch }这个模式在复杂前端交互系统里很有价值,Go 后端反而用的不多。因为 Go 的典型做法就是显式地传递上下文,职责清晰,不需要一个隐形的调度中枢。如果你发现代码里对象间引用太乱,先想想是不是分层结构本身出了问题,而不是立刻引入中介者。中介者用不好会变成所有逻辑的收纳箱。
4.6 备忘录模式:保存和恢复状态
备忘录模式用于捕获并外部化一个对象的内部状态,以便之后可以恢复。撤销操作、草稿箱、存档功能都适用。
Go 实现备忘录模式,关键点仍然是深拷贝。我前面在原型模式里强调过,这里再度踩雷:如果备忘录只保存了原对象的浅拷贝,恢复时引用的内部切片或 map 可能已经被后续操作修改了,那就不是“恢复”而是“串味”。
type EditorState struct { content string cursor int } func (e *Editor) Save() EditorState { return EditorState{content: e.content, cursor: e.cursor} } func (e *Editor) Restore(s EditorState) { e.content = s.content e.cursor = s.cursor }用这个模式时要注意存储开销。如果对象很大,每次保存一份完整快照,几分钟操作下来内存就爆了。比较常见的优化是增量快照或者只保存操作命令配合命令模式做撤销,这在实际项目里往往能省下大量内存。
5. 行为型模式(下):状态与策略
这一节覆盖剩下五个模式:观察者、状态、策略、模板方法、访问者。它们各自的侧重点很不同,但在 Go 里落地的感觉都很“Go”:要么用 channel,要么用函数,要么用接口组合。
5.1 观察者模式:事件驱动与解耦
观察者模式定义对象之间一对多的依赖关系:当被观察者状态变化时,所有观察者都会收到通知。Go 里最简洁的实现是维护一个订阅者 map,配合 channel 或者直接调用函数。
package observer type Event struct { Type string Data any } type EventBus struct { subscribers map[string][]func(Event) } func (b *EventBus) Subscribe(eventType string, fn func(Event)) { b.subscribers[eventType] = append(b.subscribers[eventType], fn) } func (b *EventBus) Publish(e Event) { for _, fn := range b.subscribers[e.Type] { fn(e) } }这个模式在 Go 后端用得非常多,消息队列、WebSocket 推送、配置热更新都是观察者思想。但我强烈建议:如果业务规模不大,不要自己造总线,直接用 channel 和 goroutine 就能完成简单的发布订阅,特殊需求再用成熟的消息队列。自己实现的 EventBus 在高并发下很容易出现发布者阻塞、订阅者 panic 导致整个链路崩溃的问题。如果订阅函数不保证安全,Publish 里务必用 goroutine 并且 recover。
5.2 状态模式:状态机比 if-else 优雅
状态模式允许对象在其内部状态改变时改变行为。状态机是它的核心实现方式。我见过很多人用一堆 if-else 处理订单状态流转,代码写到后来自己都不敢改。状态模式用状态对象替代条件的做法,在 Go 里可以用接口和 map 状态表混合实现。
package state type State interface { Handle(ctx *Order) error } type PendingState struct{} func (s PendingState) Handle(ctx *Order) error { // 待支付状态下执行支付 ctx.State = PaidState{} return nil } type PaidState struct{} func (s PaidState) Handle(ctx *Order) error { // 已支付状态下执行发货 ctx.State = ShippedState{} return nil } type Order struct { State State }我这里给出的是用状态对象做处理的方式。实际项目里如果状态和事件很多,我更推荐用集合来维护一张明确的“状态流转表”:从哪个状态 + 什么事件 -> 到哪个状态 + 执行什么动作。状态表的好处是流转关系一眼可见,不会出现隐性的非法跃迁。状态对象适合逻辑复杂、每个状态的行为差异很大的场景;状态表适合流转关系清晰、行为相对标准的场景。
5.3 策略模式:函数就是最好的策略
策略模式定义一系列算法,把它们封装起来,并且使它们可以互相替换。Go 里函数类型让策略实现变得极其简单,甚至不需要定义接口。
type SortStrategy func([]int) func BubbleSort(arr []int) { /* 冒泡排序实现 */ } func QuickSort(arr []int) { /* 快速排序实现 */ } type Sorter struct { strategy SortStrategy } func (s *Sorter) SetStrategy(strategy SortStrategy) { s.strategy = strategy } func (s *Sorter) Sort(arr []int) { s.strategy(arr) }策略模式在支付渠道选择、限流算法、序列化格式选择这些地方非常常见。Go 里我通常直接把策略作为函数参数传入,而不是定义一个持策略字段的结构体,除非策略还需要维护自己的状态。另外策略模式有一个典型问题叫“选择逻辑泄漏”:调用方不仅要传策略,还得自己知道选哪个策略,那就把判断逻辑暴露给客户端了。比较好的做法是提供一个NewSorterWithDefault或策略注册表,让默认选择逻辑收敛在自己包里。
5.4 模板方法模式:固定骨架,可插拔细节
模板方法模式定义算法骨架,让子类在不改变结构的前提下重定义某些步骤。Java 里的经典实现是抽象类加继承,Go 里更合适的做法是接口对象嵌入结构体,由外部注入具体步骤实现。
package template type DataParser interface { Parse(data []byte) (map[string]any, error) } type Parser struct { DataParser } func (p Parser) Process(data []byte) error { parsed, err := p.Parse(data) // 模板骨架的一部分 if err != nil { return err } // 统一的后续处理逻辑 return p.validateAndSave(parsed) }用嵌入接口的方式时,有一个很多人忽略的坑:如果Parser的方法里调用的是p.DataParser.Parse,而外部传入的DataParser又被换成了另一个实现,行为就会变化。模板方法模式最适合的场景是对一批流程做统一收口,比如数据处理管道、日志采集器、定时任务模板。我在团队里常用它来约定所有定时任务必须经过“初始化、拉取、处理、推送、清理”这几个步骤,每个步骤都可以被各任务定制,但整体流程不允许乱来。
5.5 访问者模式:给对象结构加新操作
访问者模式用于分离算法和对象结构:对一组对象执行某种操作,但不需要修改这些对象的类。在 Java 里是最难写的模式之一,Go 里可以借助类型断言来简化。
package visitor type Shape interface { Accept(v Visitor) } type Circle struct{ r float64 } func (c Circle) Accept(v Visitor) { v.VisitCircle(c) } type Square struct{ side float64 } func (s Square) Accept(v Visitor) { v.VisitSquare(s) } type Visitor interface { VisitCircle(Circle) VisitSquare(Square) } type AreaVisitor struct { area float64 } func (a *AreaVisitor) VisitCircle(c Circle) { a.area += 3.14 * c.r * c.r } func (a *AreaVisitor) VisitSquare(s Square) { a.area += s.side * s.side }访问者模式的代价是:每加一种新的 Shape 类型,所有访问者都要改,违反开闭原则的方向和类继承正好相反。所以在 Go 项目里我看到的大多数访问者场景都可以用更朴素的 switch 类型断言代替:直接遍历一个 interface 列表,switch v := x.(type)挨个处理。只有当对象结构非常稳定、操作维度经常扩展时才值得引入访问者。
6. 常见误区与实战建议
23 个模式全部过完一遍之后,我想单独写一节关于实战取舍的内容。这部分不是理论,是我踩过很多次坑之后总结出来的经验。
6.1 模式不是银弹,先写朴素代码
很多初学者有个误解,觉得项目里全是模式就代表高级。实际情况恰恰相反:一个可读性好的项目,大部分代码应该是平平无奇的结构体、函数、接口和循环。设计模式是发现“代码在变复杂”时的结构化解决方案,而不是代码本身的装饰品。我自己写新功能时,会先用最直白的方式写出来,跑通流程,然后回头看:哪里有重复的创建逻辑?哪里出现了难以理解的 if-else 分支?再考虑用哪个模式去收敛。直接套模式容易把简单问题复杂化。
例如策略模式,如果 at 当前只有一种支付渠道,没有任何切换的迹象,写一个PayService反而比定义PaymentStrategy接口再维护注册表要清晰得多。等到第二、第三种渠道出现时,再重构到策略模式,成本并不高。这就是“恰当时机”的意义。
6.2 Go 特性带来的“模式替代方案”
Go 语言有些特性,天然让某些模式变得多余或形态完全改变。比如:
- 函数是一等公民,策略模式、命令模式、装饰器模式都可以用函数类型完成,没必要硬套接口加结构体。
go/ast和text/scanner让自定义解释器变得少见,需要解析时优先考虑现有库。for range和 channel 内置了迭代、生成、流水线能力,迭代器模式大部分时候消失。- 接口隐式实现让适配器、装饰器、代理的写法极其轻量,一个普通包装函数就能完成。
sync.Once、sync.Pool等标准库把单例、享元的最佳实践直接封装好了。
所以用 Go 写设计模式,重点不是背 UML,而是理解“意图”,然后思考 Go 风格怎么表达。我见过有人非要在 Go 里模拟继承,搞一堆嵌套匿名结构体和重写方法,最后把自己绕晕。Go 的答案永远是组合。
6.3 并发场景下的模式变体
Go 最强的武器是 goroutine 和 channel,很多设计模式在高并发下需要做额外防护。最典型的就是单例模式:如果初始化时拉取远程配置,可能有网络延迟,sync.Once会阻塞所有调用方,此时要考虑是否用 channel 加超时。观察者模式里,如果订阅者处理太慢,发布者会被拖慢,通常要引入异步队列或者给订阅者设置独立的 channel。责任链模式在并发请求下要考虑中间件里的共享状态,每个请求最好有独立的上下文。
我在实际项目里总结出一条原则:设计模式定义的是代码形状,并发安全定义的是代码边界。先理解模式要解决的问题,再问自己“这个模式在 goroutine 同时触发时会不会出现数据竞争、死锁、阻塞泄漏”。这一步没做好的话,模式带来的优雅会被线上事故完全抹平。
6.4 怎么把这份合集真正用起来
给你几条可落地的建议。
第一,选一个你自己的项目,尝试重构一个反复出现 if-else 的方法。判断它属于哪个模式的适用场景,不要多,一次一个。
第二,读你项目依赖的框架源码,刻意寻找模式痕迹。比如用过 Web 框架的人都知道中间件是责任链,但你可能没注意过路由树的构建也是组合模式,读取请求 body 后的序列化是策略模式。带着模式视角读源码,理解会深很多。
第三,给自己定个规矩:新代码里引入了某个模式,要在注释或 commit message 里写清“为什么用”。这不是形式主义,而是防止后来者把模式当成神圣不可侵犯的教条。
最后,设计模式永远是为可读性服务的。如果一段代码用了模式之后,反而需要看半天才明白它要干什么,那这个模式就该删掉。我在 review 里经常写的批注就是:“这里直接调用函数就够了,不需要抽象接口。”
这份合集整理到这里,其实也是我自己知识体系的一次梳理。表面上讲的是 Go 语言里的 23 种设计模式,本质上讲的是一件事:怎么在代码越来越复杂之前,找到一个稳定的结构,让改动有地方落脚,让阅读有迹可循。
以后我每写一个模式,都会先想它到底在简化什么问题。比如看到sync.Once,想的是“全局初始化怎么保证并发安全”;看到中间件,想的是“请求处理链怎么解耦”;看到接口里嵌接口,想的是“产品族的变化点到底在哪”。这种思考方式比记住模式的名字和 UML 图有用得多。
如果你也准备在项目里尝试这些模式,我最后分享一个小技巧:不要一口气把所有模式都用到。挑两三个最贴合当前痛点的,用上;其他的,留在自己的“模式工具箱”里,等遇到对应的结构问题再拿出来。这样代码会一直保持干净,你的知识储备也在悄悄增长。