设计模式是Go的第二语言

不背 24 种 GOF 模式,理解四条 Go 设计理念——组合、接口、零值、并发。每一条都让传统设计模式以更简洁的形式自然涌现。

封面

你数过自己项目里有多少个 NewXxx() 吗?

不是那些标准库的 http.NewRequest() 或者 bytes.NewReader()——我是说你自己的代码。项目里有多少个工厂函数?多少个 Builder 结构体?多少个接口,每个接口又有多少个方法?

如果你的项目用了几年 Go,代码量到了一定规模,这些问题应该让你隐隐不安。

让我给你看一段代码。它来自一个真实的项目(项目名我隐去了,但结构完全真实):

type BaseHandler struct {
    DB     *sql.DB
    Logger *log.Logger
    Cache  *redis.Client
}

type UserHandler struct {
    BaseHandler
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    // ...
}

这段代码能跑。测试能通过。线上没有 Bug。但它哪哪都不对——用 Go 的嵌入模拟继承,把 BaseHandler 当作"基类",这是从 Java 带过来的思维惯性。在 Go 里,嵌入(Embedding)是组合的语法糖,不是继承的替代品。

那正确的做法是什么?

理解 Go 的设计意图——你会发现很多传统设计模式在 Go 中会以完全不同的方式呈现。更简洁,更自然,更"Go"。

讲个真事。去年我 review 一个 Go 项目,代码风格明显是"Java 译制片"——包名用 implmanagerservice,到处都是 interface + impl 的配对。我跟作者聊了聊,他 Java 写了 8 年,Go 刚上手 3 个月。他说:“我知道 Go 没有继承,所以我就用嵌入来模拟——效果不是一样吗?”

效果不一样。嵌入(Embedding)是一种特殊的组合形式——它不仅省去了你写 field.Field() 的语法糖,还让嵌入类型的方法自动提升到外层类型。但这不是继承的替代品。当你用嵌入去模拟继承树时,你失去的是 Go 最核心的能力——通过接口组合实现多态

Go 的方式完全不同:理解设计意图后,你会发现很多传统设计模式在 Go 中会以完全不同的方式呈现。更简洁,更自然,更"Go"。


一、继承之殇:组合为什么比继承更好

让我用一个更典型的例子说明问题。

假设你写了一个日志系统。需要支持多种输出目标(控制台、文件、网络)和多种格式化方式(纯文本、JSON)。在 Java 里,你可能会这么设计:

BaseLogger (level + log())
  ├── ConsoleLogger (prefix)
  ├── FileLogger (filePath)
  └── NetworkLogger (endpoint)

然后发现需要 JSON 格式化——要么在每个子类里加一遍,要么再建一个继承层级。类数量爆炸,继承链越来越深。

这是我在实际项目中见过的问题。团队从 Java 转 Go 不到半年,代码里充斥着这样的"继承模拟"——用嵌入替代继承,以为"看起来差不多"。

但 Go 不是这样设计的。

这里需要区分一个常见的误解:很多人以为 Go 的嵌入(Embedding)就是继承。在 Go 的 spec 里,嵌入被明确定义为"类型组合的一种形式"——用类型名声明字段但省略字段名。它不是继承,而是组合的语法糖。

我在团队里做过一个实验:把项目中所有用嵌入模拟继承的地方,改为显式组合(把嵌入字段改为命名字段)。结果代码量明显增加,但可读性提升了不止一个档次——因为调用方不再需要通过"基类"指针调用方法,而是直接通过组合的字段调用。

Go 的第一条设计原则:组合优于继承。

Go 没有类和继承。它有的只是结构体(struct)和接口(interface)。结构体通过嵌入实现组合,接口通过隐式实现实现多态。这不是功能缺失——这是设计选择。

同样的日志系统,用 Go 的方式:

// 输出目标接口 — 极小接口
type Output interface {
    Write(p []byte) (n int, err error)
}

// 格式化接口 — 极小接口
type Formatter interface {
    Format(level string, msg string) string
}

// 日志器 — 组合 Output + Formatter
type Logger struct {
    Output    Output
    Formatter Formatter
}

func (l *Logger) Log(level string, msg string) {
    formatted := l.Formatter.Format(level, msg)
    l.Output.Write([]byte(formatted))
}

3 个输出目标 × 2 种格式 = 6 种组合。Go 式只需要实现 3 个 Output + 2 个 Formatter。Java 式需要 6 个类(如果不用继承复用)或复杂的继承树。

继承树 vs 接口组合 — 日志系统设计对比

这是策略模式在 Go 中的自然表达。

策略模式(Strategy Pattern)的定义是:定义一系列算法,把它们封装起来,并使它们可以互相替换。在 Java 里,你需要定义策略接口、具体策略类、上下文类。在 Go 里,Output 和 Formatter 就是策略接口,Logger 就是上下文——三样东西,三个结构体/接口,零额外抽象。

这里有个关键点:这些标准库接口都是消费者定义的——不是"我有什么方法",而是"我需要什么"。

这种设计让策略模式在 Go 中变得几乎"隐形"。你不需要知道"我用了策略模式"——你只是自然地实现了接口,然后传入需要的地方。模式在 Go 中不是刻意为之的设计,而是日常编码的自然结果。

(Go 1.18+ 的泛型进一步强化了这种模式——例如 sort.Slice 可以用泛型函数替代 sort.Interface 接口,让策略更灵活。)

Go 标准库到处都是这种设计:

  • http.Handler 是一个方法的接口——所有 HTTP 处理逻辑都通过它组合
  • io.Reader / io.Writer 是一对极小接口——整个 I/O 体系建立在这两个接口上

io.Reader/Writer 接口族关系图

  • sort.Interface 是三个方法的接口——任何可排序的类型实现它

这同时解释了装饰器模式在 Go 中为什么如此简洁——

装饰器模式(Decorator Pattern)的核心是"包装一个对象,增加行为"。在 Go 中,因为接口极小,包装变得异常简单:

type Middleware func(http.Handler) http.Handler

func LoggerMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("[%s] %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

一个函数类型,一个返回 Handler 的函数——这就是一个完整的装饰器。不需要 Filter 接口,不需要 FilterChain,不需要 XML 配置。

举一个具体的例子:我参与过一个 API 网关项目,需要为每个请求执行认证、限流、日志、指标采集四个横切关注点。如果用 Java 的 Filter 模式,我们需要定义 Filter 接口、FilterChain 类、四个 Filter 实现类、web.xml 或注解配置。在 Go 中,我们用一个中间件函数类型 + 四个中间件函数就解决了——代码量显著减少,可测试性却提高了,因为每个中间件可以独立测试。

这不是 Go 比 Java 好的问题——Go 的设计哲学让装饰器模式以更低成本实现。

核心启示:Go 没有继承,但有更好的东西——接口组合。当你需要复用行为时,不要想"怎么继承",而是想"怎么组合"。策略模式和装饰器模式在 Go 中不需要额外抽象——接口本身就是它们。


二、接口之惑:为什么你的接口总是太大

另一个常见的问题:你的接口是不是都长一个样?

20 个方法,一个实现类。接口名以 Service 结尾。项目中只有一个类型实现了它。

我见过这样的代码:

type UserService interface {
    Create(ctx context.Context, user *User) error
    Get(ctx context.Context, id int64) (*User, error)
    Update(ctx context.Context, user *User) error
    // ... 还有 10 多个方法
}

这个接口只有一个实现——userServiceImpl。接口存在的唯一理由是"为了面向接口编程"。

但 Go 的接口哲学不是这样的。

Go 的第二条设计原则:接口越小越好。

Rob Pike 说过:“接口越大,抽象越弱。"(The bigger the interface, the weaker the abstraction.)

Go 标准库中最大的接口也不过几个方法。大部分接口只有一个方法——io.Readerio.Writerhttp.Handlercontext.ContextDeadline()/Done()/Err()/Value() 也才四个方法。

极小接口的价值在于:隐式实现

Go 的接口实现是隐式的——你不需要写 implements,只要类型的方法集覆盖了接口,它就自动实现了该接口。这意味着你可以为一个已有的类型定义一个只有它满足的接口,而不需要修改那个类型的代码。

这就是适配器模式在 Go 中为什么几乎不需要样板代码。

适配器模式(Adapter Pattern)的核心是"将一个类的接口转换为客户期望的另一个接口”。在 Java 里,你需要写一个 Adapter 类来实现目标接口,内部包装被适配的对象。在 Go 里,因为隐式实现,适配器可以是一个函数类型:

// http.HandlerFunc 是一个适配器
// 它将 func(ResponseWriter, *Request) 适配为 Handler 接口
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

这就是 Go 标准库中 http.HandlerFunc 的实现——7 行代码,把一个普通函数适配成 Handler 接口。不需要额外的 Adapter 类。

http.HandlerFunc 适配器 — 7 行代码的魔法

这种函数适配到接口的模式不仅用于标准库——在我们的项目中也是同样的思路。之前我参与的一个项目中,数据库查询的缓存层就是用这种方式实现的:我们定义了 UserRepository 接口(只有 GetList 两个方法),然后分别实现了 MySQLUserRepository(真实实现)和 CachedUserRepository(代理实现)。代理层对业务代码完全透明——业务层只依赖 UserRepository 接口,不知道也不需要知道有没有缓存。

这就是 Go 的接口设计带来的好处:极小接口 + 隐式实现 = 代理模式几乎零成本。

(这是最简版本。生产环境还需要考虑缓存失效策略、缓存穿透防护等——但核心模式不变:极小接口 + 隐式实现让代理模式零成本。)

代理模式(Proxy Pattern)在 Go 中同样简洁。

代理模式的核心是"控制对另一个对象的访问"。因为接口小,代理实现也简单:

// CacheProxy 是数据库查询的代理
type CacheProxy struct {
    DB     *sql.DB
    Cache  *redis.Client
}

func (p *CacheProxy) GetUser(ctx context.Context, id int64) (*User, error) {
    // 先查缓存
    if user, err := p.Cache.Get(ctx, id); err == nil {
        return user, nil
    }
    // 缓存未命中,查数据库
    user, err := p.DB.GetUser(ctx, id)
    if err != nil {
        return nil, err
    }
    // 写入缓存
    p.Cache.Set(ctx, id, user)
    return user, nil
}

因为 GetUser 是接口上的一个方法(而不是一个类上的方法),实现代理只需要实现同一个接口。不需要 CGLIB,不需要动态代理,不需要 AOP。

核心启示:Go 的接口设计哲学是"小接口 + 隐式实现"。这让你可以在不修改原有代码的情况下,为任何类型适配新接口。适配器模式和代理模式在 Go 中不需要额外框架——函数类型和隐式实现就是它们的基础。


三、对象创建:Go 的方式更直接

为什么在 Go 里写工厂模式总有一种"在穿不合身的衣服"的感觉?

不是因为工厂模式不好——是因为在 Go 中,对象创建的方式和传统 OOP 语言完全不同。

Go 的第三条设计原则:零值有用。

在 Java 中,new int[] 或者 new ArrayList<>() 创建的都是"空"对象。在 Go 中,var config Config 创建的不是"空"对象,而是一个零值初始化的对象——所有字段都是其类型的零值。

这个设计选择的影响深远。

零值意味着默认值有意义——一个零值初始化的 Config 可能就是一个可用的配置。但当配置从 5 个字段增长到 12 个字段时,零值初始化就不够用了——你需要一种方式来表达"用默认值,但允许覆盖"。

单例模式(Singleton Pattern)在 Go 中不需要全局变量。

传统的单例模式通过私有构造函数 + 静态方法确保全局唯一实例。在 Go 中,sync.Once 提供了更优雅的方式:

var (
    config Config
    once   sync.Once
)

func GetConfig() *Config {
    once.Do(func() {
        config = loadConfig() // 只会执行一次
    })
    return &config
}

sync.Once 不是"全局唯一实例"——它是"只执行一次"。这是语义上的重要区别。前者要求你保证唯一性,后者保证操作只执行一次。在 Go 的语境下,sync.Once 比传统单例更安全(线程安全)、更灵活(可以用于任意初始化逻辑)。

sync.Once 单例模式 — 只执行一次的优雅

一个具体的场景:我有一个配置结构体,最初只有 5 个字段,用构造函数传入。半年后增长到 12 个字段——其中 8 个有默认值。如果用 Builder 模式,我需要新建一个 Builder 结构体 + 8 个 WithXxx 方法 + Build()。用 Functional Options,我只需要 8 个函数。

这是我在项目中亲身经历的演变。最终我们选择了 Functional Options——不是因为 Builder 不好,而是因为 Functional Options 更"Go"。当 Go 社区在 Dave Cheney 提出这个模式后迅速采用(grpc.Dialgrpc.WithInsecure() 等),说明这已经成了 Go 社区的事实标准。

工厂模式(Factory Pattern)在 Go 中的自然形式是 NewXxx 函数。

Go 标准库中到处都是这种模式:

  • http.NewRequest() — 创建一个 Request
  • bytes.NewReader() — 创建一个 Reader
  • md5.New() — 创建一个 hash.Hash

这些函数是"简单工厂"——封装了初始化逻辑。但和传统简单工厂的区别是:它们不负责"根据条件创建不同的类型"。每个函数只创建一个类型,初始化逻辑封装在函数内部。

这就是 Go 的方式:不是"一个工厂创建所有类型",而是"每个类型有自己的 NewXxx 函数"。

顺便一提,Go 的多返回值让工厂函数可以同时返回值和错误——这在 Java 中需要异常机制处理,在 Go 中更明确。这也是 Go 的工厂函数比传统实现更简洁的原因之一。

建造者模式(Builder Pattern)在 Go 中的替代是 Functional Options。

这是 Go 社区广泛接受的模式。它的出现源于一个实际问题:当一个结构体有很多可选字段时,如何优雅地构造它?

Builder 模式是一种解法。但在 Go 中,Functional Options 更受欢迎:

// Functional Options 版本 — Go 社区标准做法
func NewHttpClient(opts ...Option) *http.Client {
    config := defaultConfig() // 默认值
    for _, opt := range opts {
        opt(&config)          // 应用每个选项
    }
    return buildClient(config)
}

// 调用方式
client := NewHttpClient(
    WithTimeout(10*time.Second),
    WithRetries(5),
    WithBaseURL("https://api.example.com"),
)

对比 Builder 模式(~40 行代码),Functional Options 只需要 ~30 行,代码量更少。更重要的是,它更符合 Go 的习惯:用函数类型替代接口,用可变参数替代链式调用。

Functional Options 流程示意 — 默认配置 → 应用选项 → 最终配置

一句话总结:Go 的对象创建不需要复杂的工厂层级。sync.Once 替代单例,NewXxx 函数替代工厂,Functional Options 替代 Builder。每种传统模式在 Go 中都有更简洁的表达。


四、并发之力:channel 让观察者模式变得简单

这是 Go 最独特的优势——并发原生。

Go 的第四条设计原则:用通信共享内存,而不是用共享内存通信。

这条哲学对设计模式的影响是根本性的。

观察者模式(Observer Pattern)在 Java 中的标准实现:定义 Subject 接口(注册、注销、通知)、Observer 接口(更新)。具体 Subject 维护一个 Observer 列表,状态变化时遍历通知。这是最典型的 GOF 模式之一,也是 Go 并发哲学最能直接挑战的模式。

在 Go 中:一个 channel 就够了。

// 事件总线 — 用 channel 实现观察者模式
type EventBus struct {
    subscribers map[string][]chan interface{}
    mu          sync.RWMutex
}

func (eb *EventBus) Subscribe(topic string) <-chan interface{} {
    eb.mu.Lock()
    defer eb.mu.Unlock()
    ch := make(chan interface{}, 1)
    eb.subscribers[topic] = append(eb.subscribers[topic], ch)
    return ch
}

func (eb *EventBus) Publish(topic string, data interface{}) {
    eb.mu.RLock()
    channels := make([]chan interface{}, len(eb.subscribers[topic]))
    copy(channels, eb.subscribers[topic])
    eb.mu.RUnlock()

    for _, ch := range channels {
        select {
        case ch <- data:
        default:
            // 订阅者处理不及时,丢弃该消息(生产环境需处理背压)
        }
    }
}

不需要 Listener 接口,不需要注册/注销的模板代码。一个带缓冲的 channel 就是观察者的通知通道。Subscribe 返回一个只读 channel,调用方用 rangeselect 接收通知——这就是 Go 式的观察者模式。

channel 实现观察者模式 — Publisher → channel → Subscribers

迭代器模式(Iterator Pattern)在 Go 中同样被 channel 简化。

传统的迭代器模式需要定义 Iterator 接口(HasNext()Next()),具体迭代器类。在 Go 中:

// 用 channel 实现迭代器
func GenerateNumbers(max int) <-chan int {
    ch := make(chan int)
    go func() {
        for i := 0; i < max; i++ {
            ch <- i
        }
        close(ch)
    }()
    return ch
}

// 使用方
for n := range GenerateNumbers(100) {
    fmt.Println(n)
}

for...range 就是迭代器的消费方式。不需要 HasNext()/Next() 方法——channel 的关闭信号就是"没有更多元素了"。

channel 迭代器 — for…range 的自然表达

之前我们讲了 HTTP 中间件——在 Go 中,这种 Pipeline 模式本质上是装饰器模式的变体(虽然 Go 社区通常称之为"中间件模式")。下面是一个基本的中间件链:

// 用 channel 实现中间件链 — 每个中间件在自己的 goroutine 中运行
type Handler func(context.Context, Request) Response

type Middleware func(Handler) Handler

// Pipeline 将多个中间件连接为管道
func Pipeline(handler Handler, middlewares ...Middleware) Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        handler = middlewares[i](handler)
    }
    return handler
}

如果加上 Go 的并发原语,中间件的表达能力更强——

func RateLimiter(rate int) Middleware {
    ticker := time.NewTicker(time.Second / time.Duration(rate))
    // 注意:生产环境需要 defer ticker.Stop() 避免资源泄漏
    return func(next Handler) Handler {
        return func(ctx context.Context, req Request) Response {
            select {
            case <-ticker.C:
                return next(ctx, req)
            case <-ctx.Done():
                return Response{Status: 429}
            }
        }
    }
}

在 Java 中实现同样的限流中间件,你需要:Filter 接口 + FilterChain + RateLimitFilter 类 + web.xml 或注解配置。在 Go 中:一个函数 + 一个 channel + select

中间件链 Pipeline 模式 — 洋葱圈结构

(实际项目中推荐使用 golang.org/x/time/rate——标准扩展库的限流器经过生产验证。这里的例子只是为了展示 channel + select 的模式。)

核心启示:Go 的并发原语(goroutine + channel)让观察者模式和迭代器模式变得异常简单。channel 本身就是观察者的通知通道,for...range 就是迭代器的消费方式。不需要额外的接口和类——Go 的并发特性已经内置了这些模式。


回到开头

还记得开头那段代码吗?

type BaseHandler struct {
    DB     *sql.DB
    Logger *log.Logger
    Cache  *redis.Client
}

type UserHandler struct {
    BaseHandler
}

用 Go 的方式重写它:

// 按职责拆分为极小接口
type UserRepository interface {
    Get(ctx context.Context, id int64) (*User, error)
}

type UserCache interface {
    Get(ctx context.Context, id int64) (*User, error)
    Set(ctx context.Context, id int64, user *User) error
}

// UserHandler 组合需要的依赖,不继承
type UserHandler struct {
    repo  UserRepository
    cache UserCache
    log   *log.Logger
}

func NewUserHandler(repo UserRepository, cache UserCache, log *log.Logger) *UserHandler {
    return &UserHandler{repo: repo, cache: cache, log: log}
}

回到开头 — 重构后的 Go 式代码

组合替代继承。极小接口。NewXxx 工厂。依赖注入。

这不是"用了设计模式"——这是理解了 Go 的设计意图

理解组合优于继承,策略模式和装饰器模式就是它的自然产物。你不需要翻 GOF 的书。

接口要小这件事搞清楚了,适配器模式和代理模式就成了日常工作。不用学"结构型模式"的分类。

接受了零值有用,sync.Once 和 Functional Options 就是顺手的选择。不用背"创建型模式"的定义。

用上了并发原生,channel 就替代了观察者和迭代器。不需要额外的框架。

把这四条哲学吃透,所谓的"设计模式"不过是在用 Go 的方式解决问题。

四条哲学总结图 — 组合/接口/零值/并发对应 Go 式设计模式

当然,组合不是银弹——当状态和行为高度耦合时,继承模型可能更自然。小接口也有代价——接口太小时,类型数量会增多,在某些场景下反而是种负担。关键是知道什么时候用什么。

如果非要总结成一件事:当你觉得某个设计模式在 Go 中写起来别扭时,停下来想想——不是这个模式有问题,是你在用 Go 写 Java。


附录:实验代码和原始数据

本文 5 组实验的代码已开源:

GitHub:zhiyulab-evidence/go-design-pattern-practice

实验 说明 目录
E1 Java in Go 模拟继承的陷阱——嵌入 vs 继承的语义差异对比 e1-java-in-go/
E2 日志系统的 Java 式(继承) vs Go 式(接口组合)代码对照 e2-java-vs-go/
E3 HTTP 中间件链的自然演化——从嵌套函数到中间件 Pipeline e3-middleware-evolution/
E4 Go 标准库中的设计模式实例——标准库模式目录 e4-stdlib-patterns/
E5 Functional Options vs Builder 模式代码量对比 e5-options-vs-builder/

每个子目录都有独立 README,说明如何复现。二进制编译产物不入库,跑实验前自己 go build


原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →