资讯动态

Go语言23种设计模式全解析:用组合与接口重写经典模式

发布时间:2026/10/14 20:38:46 来源:尧图企业网站定制
一说 23 种设计模式很多人脑子里全是类图、继承、虚函数。但在 Go 里面你会发现很多模式的“标准画法”根本画不出来——Go 压根没有继承。我最近把 GoF 的经典模式逐个用 Go 重写了一遍整理成一个合集这篇是合集的总纲不只给代码更想讲清楚为什么同一个模式在 Go 里会长成另一副样子。如果你是已经写过一段时间 Go想知道设计模式怎么落地或者刚从 Java/C 转过来被“没有继承怎么做多态”卡住这份合集都适合你。我会把每个模式的核心意图、Go 实现骨架、以及我实际踩过的坑一起讲避免你照着书抄出一个“看起来很面向对象但完全不像 Go”的代码。1. 为什么在 Go 里谈 23 种设计模式之前先忘掉“继承”1.1 GoF 的教科书画法在 Go 里会失真GoF 的 23 种设计模式本质上是在 Smalltalk / C 时代的类继承体系下总结出来的。很多模式当时的解法是“用抽象类定义骨架让子类补步骤”或者“用多层继承扩展行为”这在 C、Java 里是顺理成章的。但 Go 语言从一开始就没有继承只有组合struct 嵌入也不是继承而是一种语法糖级的委托。你用type A struct { B }的时候A 拥有 B 的方法但 A 并不是 B 的子类型接口层面的多态不靠这个来传递。所以如果照着 Java 的类图画 Go你会发现很多模式根本画不出来。例如模板方法模式Java 里是抽象类加 protected 方法Go 里没有抽象类怎么处理答案是接口加嵌入加函数字段或者直接借用sort.Interface那种思路算法骨架在外部函数里固定具体步骤由传入的类型提供。同样的意图表达方式完全不同。1.2 Go 语言的四个特性决定了所有模式的实现方式第一接口是隐式实现的。Go 里不需要写implements只要一个类型的方法集合满足接口就能传递。这个特性影响巨大适配器、代理、装饰器这些“包一层”的模式代码量会少很多而且接口可以定义在使用方而不是被依赖方天然规避了循环依赖。第二结构体嵌入虽然不等于继承但可以让我们复用方法。组合模式、装饰器模式中的“递归组合”Go 用结构体切片加接口就能表达得特别直接。关键是你要接受“组合优先于继承”不是一句口号而是写 Go 时的默认姿势。第三函数是一等公民。策略模式、命令模式、责任链模式在 Java 里可能要定义一堆函数接口和匿名类在 Go 里只需要一个type Handler func(...)。行为型模式因此被大幅简化但也带来一个问题如果滥用函数类型会让代码难以调试因为堆栈里全是匿名函数。第四goroutine 和 channel。观察者模式在 Go 里最自然的实现不是回调注册而是订阅 channel。责任链模式也可以结合并发做流水线。这个特性让 Go 的并发模式不是额外复杂度而是“用 channel 就能写出来的语言级能力”。2. 创建型模式Go 不靠构造函数靠函数2.1 单例模式直接用 sync.Once不要自己加锁单例模式的意图是保证一个类型全局只有一个实例。Go 里最简单的写法其实是包级全局变量在init里初始化这是“饿汉式”。但如果你是做懒加载或者初始化依赖运行时的某些配置就得用sync.Once。package config import sync var ( once sync.Once cfg *Config ) func Load() *Config { once.Do(func() { cfg Config{} // 在这里读取文件、初始化大对象 }) return cfg }你可能会觉得用 double-checked locking 更酷但在 Go 里直接用sync.Once就够了它内部已经把底层原子操作和锁处理好了。我见过有人自己写一套if nil { lock; if nil { create } }出问题的概率远高于收益。真正需要注意的是不要依赖sync.Once去处理“初始化失败后想重试”的场景因为 Once 只保证执行一次失败也会被记住。如果初始化可能失败可以用atomic.Value配合普通锁或者干脆不做懒加载改成启动时初始化并直接 panic。还有一个更根本的提醒单例本质是全局可变状态会影响测试的隔离性。如果你要单例只是为了省一点内存或者只是表达“这个组件全局唯一”那没问题但如果它内部有可变字段你的测试用例之间就可能互相污染。2.2 工厂方法模式与抽象工厂返回接口、隐藏具体类型Go 没有构造函数所以包级别的NewXxx函数承担了对象创建的职责。最简单的入口是一个简单工厂type Button interface { Render() } type WindowsButton struct{} func (WindowsButton) Render() {} type LinuxButton struct{} func (LinuxButton) Render() {} func NewButton(os string) Button { switch os { case windows: return WindowsButton{} default: return LinuxButton{} } }严格来说这是“简单工厂”不是工厂方法。工厂方法模式强调的是一个创建者接口由不同创建者决定实例化哪个产品type Dialog interface { CreateButton() Button } type WindowsDialog struct{} func (WindowsDialog) CreateButton() Button { return WindowsButton{} }使用方只依赖Dialog和Button接口不关心具体类型。抽象工厂模式则是进一步规定“一个工厂要能生产一系列相关产品”比如一个报表工厂必须同时产出 Header 和 Body你只需要定义type ReportFactory interface { CreateHeader() Header; CreateBody() Body }。Go 中实现这些模式时最常见的错误是返回具体类型而不是接口。我建议所有对外暴露的创建函数都写成返回接口至少返回一个不变量比如“这个函数返回的对象一定满足某行为”。这样可以让你在以后替换实现时不用改调用方。当然也别过度如果这个对象只有一个实现返回具体类型也说得通Go 社区对此并不像 Java 那样强求。2.3 建造者模式首选 Functional Options建造者模式原本是为了解决“对象构造参数太多、可读性差、默认值不清晰”的问题。经典写法是 Builder 结构体加链式方法strings.Builder就是典型例子。但在日常业务代码中我更推荐 Go 社区流行的 functional options也就是函数选项模式。type Server struct { host string port int timeout time.Duration } type Option func(*Server) func WithHost(h string) Option { return func(s *Server) { s.host h } } func WithPort(p int) Option { return func(s *Server) { s.port p } } func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout d } } func NewServer(opts ...Option) *Server { s : Server{ host: 0.0.0.0, port: 8080, timeout: 3 * time.Second, } for _, opt : range opts { opt(s) } return s }使用方可以NewServer(WithHost(127.0.0.1), WithPort(8081))只设置关心的参数其余走默认值。相比传统 Builder它的好处是不用维护一个独立的 Builder 状态结构体也不会因为链式调用中断而产生半成品对象。缺点也很明显匿名函数在堆栈中可读性略差如果你追求可读性可以用具名函数WithPort的这种写法已经足够了。2.4 原型模式必须先决定浅拷贝还是深拷贝原型模式的核心是CloneGo 里的实现很容易踩坑。因为 Go 是值传递最简单的一行cp : *p只能做浅拷贝。对于包含 slice、map、指针字段的结构体浅拷贝会让新旧两个对象共享底层数据。func (p *Product) Clone() *Product { cp : *p cp.Tags append([]string(nil), p.Tags...) return cp }如果嵌套结构体更深手动复制很痛苦可以用encoding/json的序列化做深拷贝但前提是字段都是可导出的而且 JSON 序列化可能丢字段。也可以用encoding/gob但同样的不可导出字段没法处理。我的建议是原型模式在 Go 里不是高频模式只有在创建对象成本很高、且你需要保存多个互不影响的副本时才用。真要写就先想清楚这个类型的“共享边界”在哪宁可多写两个复制函数也不要迷信“序列化一次就万事大吉”。3. 结构型模式用包裹和组合代替继承3.1 适配器模式隐式接口带来的最小改动适配器模式的本质是“让原本不兼容的接口协同工作”。Go 的隐式接口实现让适配往往不需要额外写wrapper只要目标接口的方法集合和具体类型匹配直接传就行。最典型的例子是标准库里的http.Handler它是一个接口而http.HandlerFunc是一个函数类型通过适配把普通函数变成接口实现。如果两个结构之间确实存在方法名不一致、参数不匹配的情况你才需要写一个包装结构体type LegacyStore struct{} func (l *LegacyStore) Query(id string) ([]byte, error) { return nil, nil } type NewStore interface { Get(ctx context.Context, id string) ([]byte, error) } type StoreAdapter struct { legacy *LegacyStore } func (a *StoreAdapter) Get(ctx context.Context, id string) ([]byte, error) { if ctx.Err() ! nil { return nil, ctx.Err() } return a.legacy.Query(id) }这里StoreAdapter就是适配器。注意适配器不一定非要在“引入新接口”时写。更常见的场景是你的代码里定义一个小接口第三方库的结构体恰好满足那它已经适配好了。这种“使用方定义接口”的思路是 Go 接口哲学的核心能有效避免包之间互相依赖大接口。3.2 装饰器模式日志、鉴权、重试都可以用包装函数装饰器模式的意图是动态扩展对象功能。Go 里最优雅的实现是用函数类型因为装饰器本质就是“输入一个函数输出一个增强后的函数”。type Handler func(ctx context.Context, req Request) Response func Logging(h Handler) Handler { return func(ctx context.Context, req Request) Response { log.Printf(before request) resp : h(ctx, req) log.Printf(after request) return resp } } func Metrics(h Handler) Handler { return func(ctx context.Context, req Request) Response { start : time.Now() resp : h(ctx, req) record(req, time.Since(start)) return resp } }你可以在入口处这样组装handler : Metrics(Logging(handleRequest))。注意装饰器的顺序外层函数先执行“before”部分后执行“after”部分所以顺序会影响日志内容和耗时统计范围。如果你需要装饰的是接口而不是函数也可以写一个结构体包装原接口比如type loggingReader struct { r io.Reader }实现Read方法时加日志。这里最容易犯的错误是装饰器改变了原接口的方法集或者对外暴露了包装类导致调用方还能拿到原始对象绕过装饰。记住装饰器的目标是不改变接口只增强行为。3.3 代理模式控制对真实对象的访问代理模式和装饰器结构上很像都是包一层但意图不同装饰器是增强功能代理是控制访问。代理可以做权限校验、请求过滤、缓存、远程调用代理。Go 里最常见的写法是type Service interface { Fetch(ctx context.Context) ([]byte, error) } type RealService struct{} func (RealService) Fetch(ctx context.Context) ([]byte, error) { return []byte(data), nil } type CachedProxy struct { svc Service cache sync.Map } func (p *CachedProxy) Fetch(ctx context.Context) ([]byte, error) { if v, ok : p.cache.Load(key); ok { return v.([]byte), nil } data, err : p.svc.Fetch(ctx) if err nil { p.cache.Store(key, data) } return data, err }CachedProxy 是代理而不是装饰器因为它拦截了请求用自己的缓存逻辑决定了是否调用真实对象。这个模式的实际价值在于调用方只需要依赖 Service 接口替换真实实现或代理实现都很方便。这也是 Go 接口隐式实现的好处。3.4 组合模式统一叶子与容器的接口组合模式适合表达树形结构文件系统、组织架构、菜单树。在 Go 中用接口切片写起来非常自然type Node interface { Size() int64 } type File struct { name string size int64 } func (f File) Size() int64 { return f.size } type Directory struct { name string children []Node } func (d *Directory) Add(n Node) { d.children append(d.children, n) } func (d *Directory) Size() int64 { var total int64 for _, child : range d.children { total child.Size() } return total }核心在于 Directory 和 File 都实现了 Node外部调用方不需要知道当前节点是叶子还是容器。Go 中需要注意指针接收者和值接收者的统一如果Directory.Add要求指针接收者那么children里的元素应该是*Directory或接口类型否则你往切片里加的是副本之后修改不会生效。我习惯把所有实现都写成指针接收者除非这个类型是类似小枚举的不可变值。3.5 桥接、外观、享元让“大象”拆开桥接模式要解决的问题是“抽象和实现各自独立变化”。Java 里需要抽象类加实现类Go 里只需要结构体里持有接口字段。比如一个消息通知功能发送渠道有邮件、短信消息级别有普通、紧急如果全用继承要写 4 个类桥接模式则写成type Sender interface { Send(content string) } type Notify struct { sender Sender urgent bool } func (n *Notify) Notify(content string) { // 组合不同维度的逻辑 n.sender.Send(content) }这样发送渠道和消息级别是两个维度相互独立。外观模式的目标是给复杂子系统提供一个简单门面。Go 的包设计里经常用到一个service.go文件里定义一个 Service 结构体内部聚合 Repository、Cache、Messaging 等多个组件对外只暴露几个方法。享元模式强调共享不可变对象以减少内存。Go 中可以用sync.Map做缓存但关键是共享对象必须不可变。如果对象会被修改一旦有人改了其中一个字段所有引用方都会受到影响。比如颜色对象type Color struct { r, g, b uint8 } var colorCache sync.Map func GetColor(r, g, b uint8) *Color { key : uint32(r)16 | uint32(g)8 | uint32(b) if v, ok : colorCache.Load(key); ok { return v.(*Color) } c : Color{r: r, g: g, b: b} colorCache.Store(key, c) return c }4. 行为型模式策略、观察者、责任链……都可以用函数和 channel 重写4.1 策略模式与模板方法模式策略用函数模板方法用接口策略模式在 Go 里有两种常见写法一种是用接口抽象策略另一种是直接用函数字段。接口的好处是策略本身可以带状态函数的好处是轻量。比如排序策略最简单的是type LessFunc func(i, j int) bool func SortBy(data []int, less LessFunc) { // 内部使用排序算法 }调用方传入一个闭包这就是策略模式。你不需要像 Java 那样定义Comparator接口再写一个实现类。模板方法模式在 Go 里也更像“接口加外部函数”。Go 标准库的sort.Interface就是一个很好的例子排序算法骨架在sort.Sort函数里具体比较和交换由你传入的类型实现type ByAge []Person func (p ByAge) Len() int { return len(p) } func (p ByAge) Less(i, j int) bool { return p[i].Age p[j].Age } func (p ByAge) Swap(i, j int) { p[i], p[j] p[j], p[i] } sort.Sort(ByAge(people))sort.Sort是固定的算法骨架ByAge是具体步骤两者通过接口连接。这就是 Go 对模板方法模式的重新演绎不依赖继承而是依赖接口。如果你需要一个“算法骨架在结构体内部固定但步骤可以由外部注入”的场景可以用结构体持有函数字段的方式模拟。4.2 观察者模式用 channel 和订阅表实现事件总线观察者模式在 Go 中的最佳实践是 channel 加订阅表。我给你一个简化版的事件总线type Event struct { Topic string Data interface{} } type EventBus struct { mu sync.RWMutex subs map[string][]chan Event } func (b *EventBus) Subscribe(topic string, ch chan Event) { b.mu.Lock() defer b.mu.Unlock() b.subs[topic] append(b.subs[topic], ch) } func (b *EventBus) Publish(topic string, data interface{}) { b.mu.RLock() defer b.mu.RUnlock() evt : Event{Topic: topic, Data: data} for _, ch : range b.subs[topic] { ch - evt } }这里有个大坑Publish是同步发送如果订阅者的 channel 没有消费者发布者会被卡住。解决办法是给 channel 加缓冲区或者用非阻塞发送select { case ch - evt: default: // 订阅者处理不过来丢弃或者打日志 }但我建议设计时不要默默丢弃至少要在日志里体现。观察者的另一个坑是退订。如果订阅者已经退出还在订阅表里发布者向无人接收的 channel 发送就会产生 goroutine 泄漏甚至 panic。退订实现需要一个unsubscribe机制通常用sync.Map来维护保证不会向已关闭的 channel 发送。4.3 责任链模式和命令模式中间件与操作对象责任链模式在做 Web 中间件时已经成了标配。核心思想是把每个处理节点串成链每个节点决定是否继续往下传。Go 里最简单的中间件原型type Middleware func(next Handler) Handler func Auth(m Middleware) Middleware { return func(next Handler) Handler { return Handler(func(ctx Context) { if !checkAuth(ctx) { return } next.Handle(ctx) }) } }实际项目里更常见的是维护一个[]Middleware按顺序组装成最终 Handler。责任链的好处是每个节点只关心自己的职责便于增删。命令模式则是把“请求”封装成对象用来做参数化、队列、日志回放、撤销操作。Go 里可以用type Command func() error但如果要支持撤销建议定义一个接口type Command interface { Execute() error Undo() error }我在实际经验里发现命令模式最适合“一组操作需要被记录下来并重放”的场景比如编辑器撤销重做、运维任务批量执行。如果只是调用一个函数别硬套命令模式。4.4 状态模式、备忘录模式、中介者模式状态机、快照和中央协调状态模式在 Go 里常见的方法是定义状态接口和上下文结构体。状态接口里的方法接收Context引用让状态在切换时能够改变上下文内的当前状态。简单示例type State interface { Handle(ctx *Context) } type IdleState struct{} func (s IdleState) Handle(ctx *Context) { // 执行逻辑 ctx.SetState(RunningState{}) }对于简单场景状态模式也可以用map[State]func(ctx)实现把每个状态的行为变成一个函数减少类型数量。但注意状态切换如果带很多上下文数据还是用接口和结构体清晰。备忘录模式用于保存和恢复对象状态。Go 里要重点处理深拷贝问题。我经常用json.Marshal/json.Unmarshal做快照但只适用于字段全是可导出的情况。也可以定义专门的快照结构体只保存需要恢复的字段这样更可控。中介者模式是让组件之间不直接引用而是通过一个中央对象通信。比如聊天室、GUI 框架中的事件分发。Go 中可以用一个结构体持有所有组件引用并提供Notify方法协调。要注意的是中介者容易膨胀成“上帝对象”所以要控制它的职责边界。4.5 迭代器、解释器、访问者这三个少用但要认得迭代器模式在 Go 里最常用的其实是内置的range它能遍历 slice、map、channel。如果自定义数据结构需要遍历你可以实现Next() (T, bool)或者更符合 Go 风格的是在 channel 上 range。Go 1.23 之后也开始支持 range over function这让自定义迭代器更轻量。解释器模式适合构建 DSL领域特定语言。它需要定义表达式接口和各个节点结构比如一个加减乘除表达式树每个节点实现Evaluate() float64。在 Go 里解释器代码量并不大但容易变得抽象实际项目中用得不多更多被递归下降解析器替代。访问者模式在 Go 里是最难受的一个。因为 Go 没有方法重载想实现“访问者要能根据被访问对象的具体类型做不同处理”通常只能靠类型断言switch v : node.(type) { case *NumberNode: // ... case *BinaryNode: // ... }这其实是 Go 对访问者模式的替代方案简单直接代码也更容易读。所以我不建议在 Go 里强行写经典访问者模式的类型结构而是遇到需要对多种类型做分派时直接用switch type。5. 23 种模式的 Go 实现速查表分类模式Go 实现要点常用度创建型单例sync.Once或包级变量避免手动 double-check高创建型工厂方法创建者接口 返回接口的构造函数高创建型抽象工厂工厂接口返回一组相关产品接口中创建型建造者Functional Options 或链式 Builder高创建型原型Clone 方法浅拷贝用*深拷贝序列化或手工复制低结构型适配器使用方定义小接口 包装结构体中结构型桥接结构体持有接口字段代替多维继承中结构型组合统一 Node 接口容器递归调用中结构型装饰器函数包装或接口包装保持签名不变高结构型外观Service 结构体聚合子系统对外暴露精简方法高结构型享元缓存共享不可变对象可用sync.Map低结构型代理包装接口控制访问、缓存、权限中行为型责任链中间件函数链或节点链表高行为型命令Command 接口或函数支持队列/撤销中行为型解释器表达式接口 各节点结构体低行为型迭代器内置 range自定义Next或 channel中行为型中介者中央对象持有组件并协调通信低行为型备忘录快照结构体 深拷贝低行为型观察者channel 订阅表注意退订与阻塞高行为型状态状态接口 上下文结构体或map[State]func中行为型策略函数参数或接口字段高行为型模板方法sort.Interface风格接口让人实现步骤中行为型访问者用类型断言 switch 替代经典双重分派低6. 实操心得这套合集该怎么用以及我踩过的坑6.1 别为了写模式而写模式我在代码评审里看到过很多“为了用设计模式而用”的代码。最常见的是单例一个配置类明明没有状态每次new也无所谓非搞成全局单例结果测试没法隔离。还有装饰器本来一个函数写清楚逻辑就够了非要套三四层 handler最后看堆栈像剥洋葱。设计模式的目标是解决问题不是证明你懂设计模式。我的原则是先写直白的代码等发现变化点、重复点、耦合点时再考虑用某个模式重构。模式是重构的方向不是一开始的模板。6.2 Go 实现模式的四个经典坑第一个是值接收者和指针接收者不统一。在工厂、组合、代理模式里如果你把值类型塞进接口每次调用方法都会隐式复制一份。小值类型没问题但如果结构体里有锁、切片、或者方法需要修改自身就必须用指针接收者。建议一个类型统一使用指针接收者除非你能确保它是不可变值对象。第二个是 nil 接口不等于 nil。这是 Go 里非常经典的陷阱var p *Config func Get() interface{} { return p } func main() { if Get() nil { fmt.Println(nil) } else { fmt.Println(not nil) } }函数的返回值虽然底层是 nil 指针但包在 interface 里后interface 不为 nil。这在单例模式、工厂模式里尤其容易踩。所以返回接口前务必确认底层值是否为 nil。第三个是 channel 生命周期。观察者、迭代器、责任链如果用了 channel一定要明确“谁创建谁关闭”。发布者不能向已关闭的 channel 发送订阅者不消费会造成阻塞和 goroutine 泄漏。好的做法是给每个订阅者一个独立的退订信号或者在设计事件总线时提供Unsubscribe(topic, ch)。第四个是泛型出来后不要无脑泛型化所有模式。Go 1.18 之后有人把单例、工厂、缓存全部用泛型重写看起来很炫但可读性明显下降。泛型适合容器、通用算法不适合业务语义复杂的模式。接口和函数依然是最常用的表达方式。6.3 建议的学习路线如果你刚从 C/Java 过来不要按 GoF 的目录顺序背。按工程出现频率排序我建议先学这几个单例、工厂方法、建造者、装饰器、策略、观察者、责任链。这七个在 Go 项目里出现频率最高尤其是装饰器和责任链Web 中间件天天都在用。第二梯队是适配器、代理、组合、状态、命令、外观、桥接、模板方法这些遇到具体场景时查一下就能用。第三梯队是原型、享元、中介者、备忘录、访问者、解释器主要用来读源码时能认出来而不是用来写业务。练习项目我推荐一个思路写一个极简 HTTP 框架把责任链用在中间件上、策略用在路由匹配上、模板方法用在框架骨架初始化上、装饰器用在日志和耗时统计上、观察者用在请求事件广播上。这一套练下来你对模式在 Go 中的真实面貌会熟悉很多。我个人的体会是设计模式在 Go 里更像是一本“代码嗅觉训练手册”。它不告诉你必须怎么写只告诉你哪些变化点值得被抽象。等你写多了自然就会发现真正有效的不是 23 个名字而是“面向接口、组合复用、隔离变化”这三件事。用熟前面那批高频模式剩下的遇到再翻这套合集就算没白看。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑