返回首页

Golang高级:3.7 设计模式在 Go 中的实践

很多人第一次系统学习设计模式,通常接触的是 Java、C++、C# 这一类以类继承为核心的面向对象语言。等真正进入 Go 项目之后,很快会发现一个现实:设计模式依然重要,但实现方式往往完全不同。

原因很简单。Go 没有传统意义上的类继承体系,没有构造函数重载,也不鼓励为了“套模式”而引入复杂抽象。Go 更强调的是:

  • 面向接口编程,但接口通常很小
  • 通过组合而不是继承来复用行为
  • 用函数、闭包、包级构造函数表达协作关系
  • 让代码先保持简单,再在复杂度真正出现时引入模式

所以,Go 中的设计模式,重点不是把经典 GoF 模式原封不动翻译过来,而是理解它们背后的设计动机:隔离变化、控制复杂度、提升扩展性、降低耦合。

本文聚焦四类内容:

  • 创建型模式:工厂、单例、建造者
  • 结构型模式:适配器、装饰器、代理
  • 行为型模式:策略、观察者、责任链
  • Go 特有模式:函数选项模式(Functional Options)

如果你已经具备扎实的 Go 基础,那么阅读时建议始终带着两个问题:

  1. 这个模式在 Go 中到底解决什么问题?
  2. 它和传统 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 接口
  • EmailNotifierFactorySlackNotifierFactory 等具体工厂
  • 抽象产品层次 + 抽象工厂层次

而在 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 的价值主要有三点:

  1. 把复杂对象的构造步骤拆开,调用链更清晰。
  2. 最终通过 Build() 统一做合法性校验。
  3. 屏蔽了底层对象的内部表示,调用者只关心步骤,而不关心拼装细节。

与传统 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")
}

这里有两个工程上很重要的细节:

  1. 使用 RWMutex 保护订阅者列表。
  2. 通知前复制快照,避免通知过程中订阅列表被修改导致竞态。

与传统 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 项目里,更常见的责任链实现其实是两种变体:

  1. 函数链func(next Handler) Handler
  2. 切片驱动:把一组规则函数放进切片,按顺序执行

例如 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 代码,通常不是“把模式都实现一遍”,而是做到下面几点:

  1. 先写清楚,再抽象。
  2. 优先组合,再谈继承式思维迁移。
  3. 优先小接口,而不是大而全的抽象层。
  4. 模式服务于可维护性,而不是服务于简历。

七、总结

设计模式在 Go 中并没有失效,相反,它们依然是组织复杂系统的重要工具。真正变化的是表达方式:

  • 工厂模式在 Go 中往往就是构造函数
  • 单例模式依靠 sync.Once,而不是私有构造器
  • 建造者模式保留“分步骤构造”思想,但结构通常更轻
  • 适配器、装饰器、代理高度依赖接口与组合
  • 策略、观察者、责任链经常与函数、channel、中间件模型结合
  • Functional Options 则是 Go 对“复杂构造问题”的一套非常成功的本地化答案

如果要把本文压缩成一句话,那就是:

在 Go 中学习设计模式,不是学习类图翻译,而是学习如何用接口、组合、函数和包边界,表达稳定且可扩展的协作关系。

当你能从这个角度理解模式,就不会再机械地问“Go 里有没有某某模式”,而会开始主动判断:

  • 变化点在哪里?
  • 边界应该如何划分?
  • 创建、扩展、协作和控制应该放在哪一层?

这才是设计模式真正进入工程实践的标志。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang高级:3.6 编译器与链接器

下一篇

Golang项目实践:4.1 项目架构与工程规范