很多人第一次系统学习设计模式,通常接触的是 Java、C++、C# 这一类以类继承为核心的面向对象语言。等真正进入 Go 项目之后,很快会发现一个现实:设计模式依然重要,但实现方式往往完全不同。
原因很简单。Go 没有传统意义上的类继承体系,没有构造函数重载,也不鼓励为了“套模式”而引入复杂抽象。Go 更强调的是:
- 面向接口编程,但接口通常很小
- 通过组合而不是继承来复用行为
- 用函数、闭包、包级构造函数表达协作关系
- 让代码先保持简单,再在复杂度真正出现时引入模式
所以,Go 中的设计模式,重点不是把经典 GoF 模式原封不动翻译过来,而是理解它们背后的设计动机:隔离变化、控制复杂度、提升扩展性、降低耦合。
本文聚焦四类内容:
- 创建型模式:工厂、单例、建造者
- 结构型模式:适配器、装饰器、代理
- 行为型模式:策略、观察者、责任链
- Go 特有模式:函数选项模式(Functional Options)
如果你已经具备扎实的 Go 基础,那么阅读时建议始终带着两个问题:
- 这个模式在 Go 中到底解决什么问题?
- 它和传统 OOP 写法相比,差异在哪里?
一、为什么 Go 里的设计模式不能照搬传统 OOP
在经典 OOP 语言里,很多模式是围绕下面几件事展开的:
- 通过抽象类约束扩展点
- 通过继承复用公共逻辑
- 通过访问控制限制对象创建
- 通过对象层级表达系统结构
而 Go 的设计哲学明显不同。它更偏向:
- 用组合替代继承
- 用接口解耦调用方与实现方
- 用普通函数承担很多“工厂”与“装配”职责
- 用包边界和导出规则控制可见性
这会直接影响设计模式的落地方式。
| 维度 | 传统 OOP 常见做法 | Go 中更常见的做法 |
|---|---|---|
| 类型复用 | 继承父类 | 组合字段、嵌入结构体 |
| 抽象能力 | 抽象类 + 接口 | 小接口 + 隐式实现 |
| 对象创建 | 多构造函数重载 | NewXxx(...) + 配置对象 / Functional Options |
| 行为扩展 | 子类覆写 | 装饰器、组合、函数包装 |
| 生命周期控制 | 私有构造器 + 静态方法 | 包级变量 + sync.Once |
因此,学习 Go 中的设计模式时,最重要的原则是:关注模式目的,不要执着于类图形态。
二、创建型模式:控制对象如何被创建
创建型模式的核心问题是:对象应该如何被构造,如何隐藏创建细节,以及如何在创建时约束复杂性。
在 Go 中,创建型模式往往不需要厚重的“工厂类”或“构建器家族”,很多时候一个包级函数、一个配置结构体,配合接口与校验逻辑就够了。
2.1 工厂模式:把“创建逻辑”从“使用逻辑”中分离
工厂模式的本质不是“必须有一个 Factory 类”,而是:调用方不直接依赖具体实现,而是把对象创建交给一个统一入口。
这在下面几种场景里很常见:
- 根据配置创建不同实现
- 屏蔽复杂初始化过程
- 限制调用方对底层类型的感知
- 为后续扩展实现预留统一入口
在 Go 中,工厂模式通常直接表现为 NewXxx(...) 或 CreateXxx(...) 这样的函数,而不是专门定义一个层层嵌套的工厂类体系。
下面是一个完整可运行示例:根据通知渠道创建不同的 Notifier 实现。
package main
import (
"errors"
"fmt"
"strings"
)
type Notifier interface {
Notify(user, message string) error
}
type EmailNotifier struct{}
type SlackNotifier struct{}
type SMSNotifier struct{}
func (EmailNotifier) Notify(user, message string) error {
fmt.Printf("[Email] to=%s message=%s\n", user, message)
return nil
}
func (SlackNotifier) Notify(user, message string) error {
fmt.Printf("[Slack] to=%s message=%s\n", user, message)
return nil
}
func (SMSNotifier) Notify(user, message string) error {
fmt.Printf("[SMS] to=%s message=%s\n", user, message)
return nil
}
func NewNotifier(channel string) (Notifier, error) {
switch strings.ToLower(channel) {
case "email":
return EmailNotifier{}, nil
case "slack":
return SlackNotifier{}, nil
case "sms":
return SMSNotifier{}, nil
default:
return nil, errors.New("unsupported notifier channel")
}
}
func main() {
channels := []string{"email", "slack", "sms"}
for _, channel := range channels {
notifier, err := NewNotifier(channel)
if err != nil {
fmt.Println("create notifier failed:", err)
continue
}
_ = notifier.Notify("alice", "deployment finished")
}
}
这段代码的关键点在于:
- 调用方只依赖
Notifier接口 NewNotifier集中封装创建逻辑- 后续新增
WebhookNotifier时,只需要扩展工厂逻辑
与传统 OOP 实现的差异
在 Java 一类语言中,你可能会看到:
NotifierFactory接口EmailNotifierFactory、SlackNotifierFactory等具体工厂- 抽象产品层次 + 抽象工厂层次
而在 Go 里,如果系统复杂度还没高到那个程度,这样做通常会显得过度设计。Go 更常见的写法是:
- 用一个构造函数代替工厂类
- 用
switch、注册表或映射表选择具体实现 - 用接口隔离调用方
也就是说,Go 的工厂模式更轻,更偏函数式,更强调“够用就好”。
2.2 单例模式:控制全局对象只初始化一次
单例模式的目标是:保证某个对象在进程生命周期内只被初始化一次,并提供全局访问点。
这类需求在 Go 里并不罕见,比如:
- 全局配置中心
- 数据库连接池初始化入口
- 指标注册器
- 重型缓存组件
但要特别注意:单例是一种能力,不是一种默认架构。
如果一个对象本来应该通过依赖注入传递,就不要为了“全局方便”强行做成单例。单例最常见的问题不是线程安全,而是:
- 全局状态污染测试
- 隐式依赖增加耦合
- 后期难以替换和隔离
在 Go 中,单例最标准的实现方式是 sync.Once。
package main
import (
"fmt"
"sync"
)
type ConfigCenter struct {
AppName string
Version string
}
var (
configInstance *ConfigCenter
once sync.Once
)
func GetConfigCenter() *ConfigCenter {
once.Do(func() {
fmt.Println("initializing config center...")
configInstance = &ConfigCenter{
AppName: "order-service",
Version: "v3.7.0",
}
})
return configInstance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cfg := GetConfigCenter()
fmt.Printf("goroutine=%d config=%p app=%s version=%s\n", id, cfg, cfg.AppName, cfg.Version)
}(i)
}
wg.Wait()
}
这个例子里,无论多少 goroutine 并发调用 GetConfigCenter(),初始化逻辑都只会执行一次。
与传统 OOP 实现的差异
在传统 OOP 语言里,单例常见写法通常依赖:
- 私有构造器
- 静态实例字段
- 静态
GetInstance()方法 - 双重检查锁等并发控制
Go 没有私有构造器这一说,但有包级作用域和导出规则,所以常见方式变成了:
- 包内持有实例变量
- 用
sync.Once保证线程安全初始化 - 对外暴露获取函数
也就是说,Go 版单例的重点不在“模拟类的私有构造”,而在利用包边界和并发原语表达唯一性。
什么时候应该慎用单例
如果你发现自己正在做下面这些事情,就要警惕了:
- 在业务逻辑里到处直接取全局单例
- 测试中很难替换真实依赖
- 一个对象既有共享状态又需要按租户隔离
这时更好的方案通常是:
- 显式构造对象
- 通过依赖注入传递
- 把“全局唯一”缩小到某个容器或模块范围内
2.3 建造者模式:分步骤构造复杂对象
建造者模式适合处理这样一类对象:
- 字段很多
- 构造步骤有顺序要求
- 部分字段可选
- 最终对象在创建前需要统一校验
在 Go 里,建造者模式不像 Java 那样常见,因为很多情况下:
- 结构体字面量已经足够清晰
- 简单场景下构造函数就能解决问题
- 更复杂的配置通常会被 Functional Options 替代
但当对象构造过程确实包含“多步骤、可选项、校验、最终产物生成”时,Builder 依然很有价值。
下面是一个完整可运行示例:构建 SQL 查询对象。
package main
import (
"errors"
"fmt"
"strings"
)
type Query struct {
SelectFields []string
Table string
WhereClauses []string
OrderBy string
Limit int
}
type QueryBuilder struct {
query Query
}
func NewQueryBuilder() *QueryBuilder {
return &QueryBuilder{
query: Query{
SelectFields: []string{"*"},
Limit: -1,
},
}
}
func (b *QueryBuilder) Select(fields ...string) *QueryBuilder {
if len(fields) > 0 {
b.query.SelectFields = fields
}
return b
}
func (b *QueryBuilder) From(table string) *QueryBuilder {
b.query.Table = table
return b
}
func (b *QueryBuilder) Where(condition string) *QueryBuilder {
if condition != "" {
b.query.WhereClauses = append(b.query.WhereClauses, condition)
}
return b
}
func (b *QueryBuilder) Order(field string) *QueryBuilder {
b.query.OrderBy = field
return b
}
func (b *QueryBuilder) SetLimit(limit int) *QueryBuilder {
b.query.Limit = limit
return b
}
func (b *QueryBuilder) Build() (string, error) {
if b.query.Table == "" {
return "", errors.New("table is required")
}
parts := []string{
"SELECT " + strings.Join(b.query.SelectFields, ", "),
"FROM " + b.query.Table,
}
if len(b.query.WhereClauses) > 0 {
parts = append(parts, "WHERE "+strings.Join(b.query.WhereClauses, " AND "))
}
if b.query.OrderBy != "" {
parts = append(parts, "ORDER BY "+b.query.OrderBy)
}
if b.query.Limit >= 0 {
parts = append(parts, fmt.Sprintf("LIMIT %d", b.query.Limit))
}
return strings.Join(parts, " "), nil
}
func main() {
sql, err := NewQueryBuilder().
Select("id", "name", "status").
From("users").
Where("status = 'active'").
Where("deleted_at IS NULL").
Order("created_at DESC").
SetLimit(10).
Build()
if err != nil {
fmt.Println("build query failed:", err)
return
}
fmt.Println(sql)
}
这个例子里,Builder 的价值主要有三点:
- 把复杂对象的构造步骤拆开,调用链更清晰。
- 最终通过
Build()统一做合法性校验。 - 屏蔽了底层对象的内部表示,调用者只关心步骤,而不关心拼装细节。
与传统 OOP 实现的差异
在传统 OOP 中,Builder 常常配合:
Director角色- 抽象 Builder 接口
- 多个具体 Builder
- 复杂的产品层次
Go 里通常不会把 Builder 拆到这么重。更常见的方式是:
- 一个结构体保存构建状态
- 若干链式方法逐步设置字段
- 一个
Build()做校验并输出结果
如果没有多种构建流程,也完全没必要引入 Director。这是 Go 很典型的风格:只保留能带来实际收益的那部分模式结构。
三、结构型模式:组织对象之间的关系
结构型模式关注的是:已有类型如何协作,如何在不大改原有代码的前提下,调整接口、扩展行为、控制访问。
在 Go 中,结构型模式通常非常实用,因为 Go 天然支持:
- 小接口
- 组合
- 函数包装
- 嵌入与委托
3.1 适配器模式:让不兼容接口协同工作
适配器模式解决的问题是:旧接口已经存在,但新系统需要另一种调用方式,如何在不修改原有实现的前提下接起来。
典型场景包括:
- 接入历史遗留组件
- 包装第三方 SDK
- 统一多种外部服务的调用协议
下面这个例子中,系统内部约定日志输出要满足 Logger 接口,但已有的老组件只提供 WriteLog(string) 方法。我们通过适配器把旧能力接入新系统。
package main
import "fmt"
type Logger interface {
Info(message string)
}
type LegacyLogger struct{}
func (LegacyLogger) WriteLog(line string) {
fmt.Println("[LEGACY]", line)
}
type LoggerAdapter struct {
legacy LegacyLogger
}
func (a LoggerAdapter) Info(message string) {
a.legacy.WriteLog("INFO: " + message)
}
type UserService struct {
logger Logger
}
func (s UserService) CreateUser(name string) {
s.logger.Info("creating user: " + name)
fmt.Println("user created:", name)
}
func main() {
adapter := LoggerAdapter{legacy: LegacyLogger{}}
service := UserService{logger: adapter}
service.CreateUser("alice")
}
与传统 OOP 实现的差异
在传统 OOP 里,适配器常常依赖类继承或对象适配两套写法;而在 Go 中,最常见的方式其实非常朴素:
- 目标接口由调用方定义
- 适配器只是一个薄薄的结构体
- 通过组合已有对象并实现目标接口完成转换
这也是 Go 的一个关键思维:接口属于消费者,不一定属于生产者。
换句话说,适配器模式在 Go 里往往更自然,因为你不需要改旧类型,只需要定义“我现在想怎么用它”。
3.2 装饰器模式:在不改原对象的前提下叠加行为
装饰器模式的核心是:保持原有能力不变,在外围动态附加额外行为。
常见附加行为包括:
- 日志
- 监控
- 缓存
- 权限检查
- 恢复 panic
- 限流
这个模式在 Go 中尤其常见,因为 Go 非常适合用函数包装函数。HTTP 中间件本质上就是装饰器模式的工程化形态。
下面是一个完整可运行示例:对订单处理函数叠加日志和耗时统计。
package main
import (
"fmt"
"time"
)
type OrderHandler func(orderID string) error
func ProcessOrder(orderID string) error {
fmt.Println("processing order:", orderID)
time.Sleep(100 * time.Millisecond)
fmt.Println("order processed:", orderID)
return nil
}
func WithLogging(next OrderHandler) OrderHandler {
return func(orderID string) error {
fmt.Println("[log] start order:", orderID)
err := next(orderID)
if err != nil {
fmt.Println("[log] failed order:", orderID, "err:", err)
return err
}
fmt.Println("[log] success order:", orderID)
return nil
}
}
func WithTiming(next OrderHandler) OrderHandler {
return func(orderID string) error {
start := time.Now()
defer func() {
fmt.Println("[metric] duration:", time.Since(start))
}()
return next(orderID)
}
}
func main() {
handler := WithTiming(WithLogging(ProcessOrder))
if err := handler("ORD-1001"); err != nil {
fmt.Println("handle order failed:", err)
}
}
这个例子和传统“对象包装对象”的装饰器相比,更贴近 Go 的日常写法。因为在 Go 中:
- 行为本身可以直接是函数
- 装饰动作可以通过高阶函数完成
- 多层装饰天然支持链式组合
与传统 OOP 实现的差异
在传统 OOP 里,装饰器常见结构是:
- 组件接口
- 具体组件
- 装饰器基类
- 各种具体装饰器子类
而在 Go 中,尤其是处理流程型逻辑时,很多装饰器直接表现为:
- 一个函数类型
- 一组
func(next T) T的包装器
所以你会看到 Go 生态里大量类似下面的写法:
func Middleware(next Handler) Handler
这本质上就是装饰器模式,只是用函数而不是类层次来表达。
3.3 代理模式:由中间对象控制访问路径
代理模式的目标是:由代理对象代替真实对象对外提供能力,并在访问前后加入额外控制逻辑。
它和装饰器很像,但关注点略有不同:
- 装饰器更强调“增强行为”
- 代理更强调“控制访问”
代理常见于:
- 远程调用封装
- 缓存代理
- 权限代理
- 延迟加载
- 熔断与降级
下面是一个可运行示例:通过代理给用户资料查询增加缓存能力。
package main
import (
"fmt"
"time"
)
type UserFinder interface {
Find(userID string) string
}
type RemoteUserService struct{}
func (RemoteUserService) Find(userID string) string {
fmt.Println("[remote] fetching user from remote service...")
time.Sleep(200 * time.Millisecond)
return "user:" + userID
}
type CachedUserProxy struct {
target UserFinder
cache map[string]string
}
func NewCachedUserProxy(target UserFinder) *CachedUserProxy {
return &CachedUserProxy{
target: target,
cache: make(map[string]string),
}
}
func (p *CachedUserProxy) Find(userID string) string {
if value, ok := p.cache[userID]; ok {
fmt.Println("[proxy] hit cache")
return value
}
fmt.Println("[proxy] cache miss")
value := p.target.Find(userID)
p.cache[userID] = value
return value
}
func main() {
proxy := NewCachedUserProxy(RemoteUserService{})
fmt.Println(proxy.Find("42"))
fmt.Println(proxy.Find("42"))
}
第一次调用会访问远程服务,第二次则直接命中缓存。
与传统 OOP 实现的差异
传统 OOP 中,代理模式往往围绕对象层次和访问控制展开,比如虚代理、远程代理、保护代理等。Go 里当然也能这样分类,但实践中更常见的写法是:
- 代理对象实现和真实对象相同的接口
- 内部持有真实对象引用
- 在转发前后插入缓存、鉴权、限流、重试等逻辑
换句话说,Go 的代理模式不是靠“类图优雅”,而是靠接口一致性 + 组合转发实现的。
四、行为型模式:组织行为与职责流转
行为型模式关注的是:对象之间如何协作、如何分派责任、如何在运行时切换行为。
Go 的行为型模式通常比传统 OOP 更贴近“流程设计”,因为 Go 很擅长用:
- 小接口
- 函数值
- channel
- 中间件链
来组织运行时行为。
4.1 策略模式:把可替换算法独立出来
策略模式解决的问题是:同一个业务目标下,有多种算法或执行策略,希望在运行时灵活切换。
典型场景:
- 不同的折扣计算方式
- 不同的路由策略
- 不同的序列化策略
- 不同的重试策略
下面是一个完整可运行示例:订单金额支持多种优惠策略。
package main
import "fmt"
type DiscountStrategy interface {
Apply(amount float64) float64
Name() string
}
type NoDiscount struct{}
type PercentageDiscount struct {
Rate float64
}
type FullReduction struct {
Threshold float64
Reduce float64
}
func (NoDiscount) Apply(amount float64) float64 {
return amount
}
func (NoDiscount) Name() string {
return "no discount"
}
func (p PercentageDiscount) Apply(amount float64) float64 {
return amount * (1 - p.Rate)
}
func (p PercentageDiscount) Name() string {
return fmt.Sprintf("percentage %.0f%%", p.Rate*100)
}
func (f FullReduction) Apply(amount float64) float64 {
if amount >= f.Threshold {
return amount - f.Reduce
}
return amount
}
func (f FullReduction) Name() string {
return fmt.Sprintf("full reduction %.0f-%.0f", f.Threshold, f.Reduce)
}
type CheckoutService struct {
strategy DiscountStrategy
}
func NewCheckoutService(strategy DiscountStrategy) *CheckoutService {
return &CheckoutService{strategy: strategy}
}
func (s *CheckoutService) SetStrategy(strategy DiscountStrategy) {
s.strategy = strategy
}
func (s *CheckoutService) Pay(amount float64) {
finalAmount := s.strategy.Apply(amount)
fmt.Printf("strategy=%s original=%.2f final=%.2f\n", s.strategy.Name(), amount, finalAmount)
}
func main() {
service := NewCheckoutService(NoDiscount{})
service.Pay(120)
service.SetStrategy(PercentageDiscount{Rate: 0.2})
service.Pay(120)
service.SetStrategy(FullReduction{Threshold: 100, Reduce: 15})
service.Pay(120)
}
这个模式的关键不是“多态”本身,而是:把变化点从主流程中剥离出来。
主流程只关心“怎么算”,不关心“具体是哪种算法”。
与传统 OOP 实现的差异
在传统 OOP 里,策略模式通常通过一组策略类实现统一接口,再由上下文对象持有策略引用。Go 当然也能这么写,上面就是标准版本。
但 Go 还有一个非常常见的轻量变体:直接用函数作为策略。
比如:
type DiscountFunc func(amount float64) float64
如果策略不需要额外状态,用函数比定义多个类型还轻。因此在 Go 中,策略模式常常会沿着两条路实现:
- 有状态、可扩展策略:用接口
- 轻量、短生命周期策略:用函数
4.2 观察者模式:在状态变化时通知订阅者
观察者模式用于解决:一个对象状态发生变化后,需要把事件通知给多个依赖方。
典型场景包括:
- 领域事件广播
- 配置变更通知
- 任务状态变化回调
- UI / WebSocket 推送
在 Go 中,观察者模式既可以按经典写法实现,也常常会被 channel、事件总线、消息队列替代。换句话说,Go 并不是不需要观察者,而是可以用更并发友好的机制承载它。
下面给出一个经典但线程安全的实现。
package main
import (
"fmt"
"sync"
)
type Observer interface {
Update(event string)
}
type UserObserver struct {
Name string
}
func (o UserObserver) Update(event string) {
fmt.Printf("observer=%s received event=%s\n", o.Name, event)
}
type Subject struct {
mu sync.RWMutex
observers []Observer
}
func (s *Subject) Subscribe(observer Observer) {
s.mu.Lock()
defer s.mu.Unlock()
s.observers = append(s.observers, observer)
}
func (s *Subject) Notify(event string) {
s.mu.RLock()
observers := append([]Observer(nil), s.observers...)
s.mu.RUnlock()
for _, observer := range observers {
observer.Update(event)
}
}
func main() {
subject := &Subject{}
subject.Subscribe(UserObserver{Name: "audit-service"})
subject.Subscribe(UserObserver{Name: "message-service"})
subject.Subscribe(UserObserver{Name: "metrics-service"})
subject.Notify("order_created")
}
这里有两个工程上很重要的细节:
- 使用
RWMutex保护订阅者列表。 - 通知前复制快照,避免通知过程中订阅列表被修改导致竞态。
与传统 OOP 实现的差异
在传统 OOP 设计里,观察者模式常见于 GUI 事件系统,通常强调对象之间的订阅/取消订阅关系。Go 中的经典实现和它并无本质不同,但更常见的实践差异有两点:
- 如果事件流是异步的,Go 往往直接用
channel作为通知机制。 - 如果订阅者很多、事件可靠性要求高,通常会上升为消息总线或 MQ,而不是停留在内存对象级别。
所以在 Go 中,观察者模式经常是一个“思想原型”,最终落地可能演化成:
- 内存订阅模型
- channel 广播模型
- 事件总线模型
- MQ 驱动的异步订阅模型
4.3 责任链模式:让多个处理节点依次决定是否处理请求
责任链模式的核心是:把处理逻辑拆成多个节点,让请求按顺序流过这些节点,每个节点决定继续传递、直接处理或中断返回。
这在 Go 服务端开发里几乎无处不在:
- HTTP 中间件链
- 参数校验链
- 风控规则链
- 审批流节点
- 日志处理管道
下面是一个完整可运行示例:对订单请求做校验、库存检查和支付处理。
package main
import (
"errors"
"fmt"
)
type OrderRequest struct {
UserID string
Product string
Quantity int
Paid bool
}
type Handler interface {
SetNext(handler Handler)
Handle(req *OrderRequest) error
}
type BaseHandler struct {
next Handler
}
func (h *BaseHandler) SetNext(handler Handler) {
h.next = handler
}
func (h *BaseHandler) CallNext(req *OrderRequest) error {
if h.next != nil {
return h.next.Handle(req)
}
return nil
}
type ValidateHandler struct {
BaseHandler
}
func (h *ValidateHandler) Handle(req *OrderRequest) error {
if req.UserID == "" || req.Product == "" || req.Quantity <= 0 {
return errors.New("invalid order request")
}
fmt.Println("validate handler passed")
return h.CallNext(req)
}
type InventoryHandler struct {
BaseHandler
}
func (h *InventoryHandler) Handle(req *OrderRequest) error {
if req.Quantity > 5 {
return errors.New("insufficient inventory")
}
fmt.Println("inventory handler passed")
return h.CallNext(req)
}
type PaymentHandler struct {
BaseHandler
}
func (h *PaymentHandler) Handle(req *OrderRequest) error {
req.Paid = true
fmt.Println("payment handler finished, paid=true")
return h.CallNext(req)
}
func main() {
validate := &ValidateHandler{}
inventory := &InventoryHandler{}
payment := &PaymentHandler{}
validate.SetNext(inventory)
inventory.SetNext(payment)
req := &OrderRequest{
UserID: "u1001",
Product: "book",
Quantity: 2,
}
if err := validate.Handle(req); err != nil {
fmt.Println("order failed:", err)
return
}
fmt.Printf("order success: %+v\n", *req)
}
与传统 OOP 实现的差异
在经典 OOP 中,责任链模式常用抽象处理器作为父类,每个子类决定是否转发给下一个处理器。Go 当然也能这么写,上面的 BaseHandler 就保留了这种结构。
但在真实 Go 项目里,更常见的责任链实现其实是两种变体:
- 函数链:
func(next Handler) Handler - 切片驱动:把一组规则函数放进切片,按顺序执行
例如 Web 框架中的 middleware,本质就是责任链模式的 Go 化实现。因此如果你能看懂责任链,就能更好理解 Gin、Echo、Chi 这些框架的处理流程。
五、Go 特有模式:函数选项模式(Functional Options)
如果要在“Go 实践里最常见、最有代表性、最值得深入理解”的模式里选一个,Functional Options 几乎一定在榜单前列。
它解决的是一个极其现实的问题:
当一个对象有很多可选配置项时,如何在保持 API 易用性的同时,避免构造函数爆炸、参数顺序难记、零值语义混乱、后续扩展困难?
5.1 为什么 Go 特别需要 Functional Options
设想你要创建一个客户端:
- 必填:服务地址
- 可选:超时、重试次数、重试间隔、默认请求头、日志器、传输层
- 未来还可能扩展:熔断、限流、Tracing、Metrics、代理设置
如果只用普通构造函数,很容易出现这种接口:
NewClient(endpoint string, timeout time.Duration, retry int, retryBackoff time.Duration, headers map[string]string, logger Logger, transport Transport)
问题会立刻出现:
- 参数太多,顺序难记
- 可读性差
- 某些参数只是可选配置,却被迫传入
- 后续增加新参数会破坏调用方
- 零值到底表示“未设置”还是“显式设置为 0”经常说不清
Functional Options 的核心思想是:
- 构造函数只接收必要参数和一组 Option
- 每个 Option 负责修改部分配置
- 默认值由构造函数统一设定
- 最终在构造阶段统一校验
这让 API 在扩展性和可读性之间取得了非常好的平衡。
5.2 生产级示例:构建一个可配置 API Client
下面这个示例不是玩具版,而是尽量贴近生产设计:
- 有默认配置
- 有输入校验
- 支持依赖注入(日志器、传输层)
- 对 map 做深拷贝,避免外部修改污染内部状态
- 支持失败重试
- 保持调用端可读性
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
type Logger interface {
Printf(format string, args ...any)
}
type StdLogger struct{}
func (StdLogger) Printf(format string, args ...any) {
fmt.Printf(format+"\n", args...)
}
type Transport interface {
Do(ctx context.Context, req Request) (Response, error)
}
type Request struct {
Method string
Path string
Headers map[string]string
}
type Response struct {
StatusCode int
Body string
}
type Client struct {
endpoint string
timeout time.Duration
maxRetries int
retryBackoff time.Duration
headers map[string]string
logger Logger
transport Transport
}
type Option func(*Client) error
func NewClient(endpoint string, opts ...Option) (*Client, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return nil, errors.New("endpoint is required")
}
c := &Client{
endpoint: endpoint,
timeout: 2 * time.Second,
maxRetries: 0,
retryBackoff: 100 * time.Millisecond,
headers: map[string]string{
"User-Agent": "go-design-patterns-client/1.0",
},
logger: StdLogger{},
transport: &MockTransport{failUntil: 0},
}
for _, opt := range opts {
if err := opt(c); err != nil {
return nil, err
}
}
if c.timeout <= 0 {
return nil, errors.New("timeout must be positive")
}
if c.maxRetries < 0 {
return nil, errors.New("maxRetries cannot be negative")
}
if c.retryBackoff < 0 {
return nil, errors.New("retryBackoff cannot be negative")
}
if c.transport == nil {
return nil, errors.New("transport cannot be nil")
}
if c.logger == nil {
return nil, errors.New("logger cannot be nil")
}
return c, nil
}
func WithTimeout(timeout time.Duration) Option {
return func(c *Client) error {
c.timeout = timeout
return nil
}
}
func WithMaxRetries(n int) Option {
return func(c *Client) error {
c.maxRetries = n
return nil
}
}
func WithRetryBackoff(backoff time.Duration) Option {
return func(c *Client) error {
c.retryBackoff = backoff
return nil
}
}
func WithHeader(key, value string) Option {
return func(c *Client) error {
key = strings.TrimSpace(key)
if key == "" {
return errors.New("header key cannot be empty")
}
if c.headers == nil {
c.headers = make(map[string]string)
}
c.headers[key] = value
return nil
}
}
func WithHeaders(headers map[string]string) Option {
return func(c *Client) error {
if c.headers == nil {
c.headers = make(map[string]string)
}
for k, v := range headers {
if strings.TrimSpace(k) == "" {
return errors.New("header key cannot be empty")
}
c.headers[k] = v
}
return nil
}
}
func WithLogger(logger Logger) Option {
return func(c *Client) error {
c.logger = logger
return nil
}
}
func WithTransport(transport Transport) Option {
return func(c *Client) error {
c.transport = transport
return nil
}
}
func (c *Client) Get(ctx context.Context, path string) (Response, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
req := Request{
Method: "GET",
Path: path,
Headers: cloneMap(c.headers),
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
resp, err := c.transport.Do(ctx, req)
if err == nil {
c.logger.Printf("request success endpoint=%s path=%s attempt=%d status=%d", c.endpoint, path, attempt+1, resp.StatusCode)
return resp, nil
}
lastErr = err
c.logger.Printf("request failed endpoint=%s path=%s attempt=%d err=%v", c.endpoint, path, attempt+1, err)
if attempt == c.maxRetries {
break
}
select {
case <-time.After(c.retryBackoff):
case <-ctx.Done():
return Response{}, ctx.Err()
}
}
return Response{}, fmt.Errorf("request exhausted retries: %w", lastErr)
}
func cloneMap(src map[string]string) map[string]string {
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
type MockTransport struct {
failUntil int
calls int
}
func (m *MockTransport) Do(ctx context.Context, req Request) (Response, error) {
m.calls++
select {
case <-time.After(50 * time.Millisecond):
case <-ctx.Done():
return Response{}, ctx.Err()
}
if m.calls <= m.failUntil {
return Response{}, errors.New("temporary network error")
}
return Response{
StatusCode: 200,
Body: "ok: " + req.Path,
}, nil
}
func main() {
transport := &MockTransport{failUntil: 2}
client, err := NewClient(
"https://api.example.com",
WithTimeout(1500*time.Millisecond),
WithMaxRetries(3),
WithRetryBackoff(120*time.Millisecond),
WithHeader("X-Env", "prod"),
WithHeaders(map[string]string{
"X-App": "order-service",
}),
WithTransport(transport),
)
if err != nil {
fmt.Println("create client failed:", err)
return
}
resp, err := client.Get(context.Background(), "/users/42")
if err != nil {
fmt.Println("request failed:", err)
return
}
fmt.Printf("final response: status=%d body=%s\n", resp.StatusCode, resp.Body)
}
5.3 这个示例为什么算“生产级思路”
很多文章里的 Functional Options 示例只停留在:
type Option func(*Config)
然后简单改几个字段就结束了。这样的示例可以说明语法,但不足以说明工程价值。真正有价值的是:它如何帮助你构建稳定、可扩展、可维护的初始化协议。
上面的示例体现了几个关键点。
1. 默认值集中管理
默认值由 NewClient 统一设定:
- 默认超时
- 默认重试参数
- 默认请求头
- 默认日志器
- 默认传输层
这意味着调用方只需要关心“我要改什么”,而不是“我要把所有参数都传一遍”。
2. 可选项具备良好可读性
调用代码读起来非常接近自然语言:
client, err := NewClient(
"https://api.example.com",
WithTimeout(1500*time.Millisecond),
WithMaxRetries(3),
WithRetryBackoff(120*time.Millisecond),
)
相比长参数列表,这种方式更不容易出错,也更方便 code review。
3. Option 可以携带校验逻辑
像 WithHeader 这种 Option,本身就可以校验输入是否合法;而构造函数在最终阶段还能做全局一致性校验。
这比“先随便塞字段,运行时再炸”要稳健得多。
4. 依赖注入能力更自然
日志器和传输层都通过 Option 注入,这样带来两个直接收益:
- 线上环境可以注入真实实现
- 测试环境可以注入 mock / fake 实现
这也是为什么 Go 社区大量基础库都偏爱 Functional Options:它让 API 的扩展与测试都更舒服。
5. 避免配置别名污染
headers 在真正发请求时做了拷贝,这类细节非常重要。否则如果外部把一个 map 传进来,后续又修改这个 map,就可能影响客户端内部状态。
设计模式的真正价值,往往就体现在这种“对边界的谨慎处理”上。
5.4 Functional Options 与 Builder 的区别
很多人会把 Builder 和 Functional Options 混在一起。它们确实都在解决“复杂对象构造”的问题,但重点不同。
| 维度 | Builder | Functional Options |
|---|---|---|
| 核心目标 | 分步骤构造复杂对象 | 优雅表达大量可选配置 |
| 典型形式 | 链式方法 + Build() |
NewXxx(opts ...Option) |
| 是否强调构造过程 | 是 | 不一定 |
| 是否适合参数扩展 | 可以,但通常不如 Options 灵活 | 非常适合 |
| Go 中常见程度 | 中等 | 很高 |
简单理解:
- 如果你要表达的是“构造过程分阶段进行”,更像 Builder。
- 如果你要表达的是“对象有很多可选配置”,更像 Functional Options。
5.5 与传统 OOP 实现的差异
在传统 OOP 语言中,解决“构造参数很多”问题的常见思路是:
- 构造函数重载
- Builder
- 可变配置对象
- Setter 链
Go 没有构造函数重载,因此 Functional Options 自然成为一种非常优雅的替代方案。它的优势在于:
- 不依赖语言层面的重载能力
- 对新增配置项非常友好
- 不会破坏旧调用方
- 可读性明显优于超长参数列表
这可以说是 Go 社区把“经典构造问题”重新本地化之后形成的一种习惯用法。
5.6 Functional Options 的适用边界
虽然它很好用,但也不要滥用。以下场景就不一定适合:
- 只有 2~3 个参数,直接构造函数更简单
- 所有字段都必须填写,配置对象更清晰
- Option 之间有强顺序依赖,可能导致初始化语义变得隐晦
一个很实用的判断标准是:
当你开始担心“构造函数参数越来越多、可选项越来越多、未来扩展会不会破坏兼容性”时,Functional Options 往往就值得引入了。
六、在 Go 项目里如何选择这些模式
设计模式不是越多越好,而是要和 Go 的简洁风格匹配。实际项目里可以参考下面这张表:
| 场景 | 更适合的模式 | 说明 |
|---|---|---|
| 根据配置实例化不同实现 | 工厂模式 | 隐藏创建逻辑,隔离具体类型 |
| 全局只初始化一次的重量级组件 | 单例模式 | 用 sync.Once,但谨慎控制全局状态 |
| 对象构造过程复杂、分步骤且需校验 | 建造者模式 | 适合构造过程本身很重要的场景 |
| 对接旧系统或第三方 SDK | 适配器模式 | 让不兼容接口协同工作 |
| 给处理流程叠加日志、监控、鉴权 | 装饰器模式 | Go 中经常表现为 middleware |
| 为真实对象增加缓存、权限、远程控制 | 代理模式 | 强调访问控制与转发 |
| 同一业务流程切换不同算法 | 策略模式 | 可用接口,也可用函数 |
| 一个状态变化需要通知多个订阅方 | 观察者模式 | 小规模内存订阅或事件总线 |
| 多步骤校验/处理/拦截 | 责任链模式 | Go Web 框架中极常见 |
| 构造器可选项很多且要保持 API 可扩展 | Functional Options | Go 中非常常见、非常实用 |
真正成熟的 Go 代码,通常不是“把模式都实现一遍”,而是做到下面几点:
- 先写清楚,再抽象。
- 优先组合,再谈继承式思维迁移。
- 优先小接口,而不是大而全的抽象层。
- 模式服务于可维护性,而不是服务于简历。
七、总结
设计模式在 Go 中并没有失效,相反,它们依然是组织复杂系统的重要工具。真正变化的是表达方式:
- 工厂模式在 Go 中往往就是构造函数
- 单例模式依靠
sync.Once,而不是私有构造器 - 建造者模式保留“分步骤构造”思想,但结构通常更轻
- 适配器、装饰器、代理高度依赖接口与组合
- 策略、观察者、责任链经常与函数、channel、中间件模型结合
- Functional Options 则是 Go 对“复杂构造问题”的一套非常成功的本地化答案
如果要把本文压缩成一句话,那就是:
在 Go 中学习设计模式,不是学习类图翻译,而是学习如何用接口、组合、函数和包边界,表达稳定且可扩展的协作关系。
当你能从这个角度理解模式,就不会再机械地问“Go 里有没有某某模式”,而会开始主动判断:
- 变化点在哪里?
- 边界应该如何划分?
- 创建、扩展、协作和控制应该放在哪一层?
这才是设计模式真正进入工程实践的标志。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!