
你数过自己项目里有多少个 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 译制片"——包名用 impl、manager、service,到处都是 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 个类(如果不用继承复用)或复杂的继承树。

这是策略模式在 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 体系建立在这两个接口上

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.Reader、io.Writer、http.Handler、context.Context 的 Deadline()/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 类。

这种函数适配到接口的模式不仅用于标准库——在我们的项目中也是同样的思路。之前我参与的一个项目中,数据库查询的缓存层就是用这种方式实现的:我们定义了 UserRepository 接口(只有 Get 和 List 两个方法),然后分别实现了 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 比传统单例更安全(线程安全)、更灵活(可以用于任意初始化逻辑)。

一个具体的场景:我有一个配置结构体,最初只有 5 个字段,用构造函数传入。半年后增长到 12 个字段——其中 8 个有默认值。如果用 Builder 模式,我需要新建一个 Builder 结构体 + 8 个 WithXxx 方法 + Build()。用 Functional Options,我只需要 8 个函数。
这是我在项目中亲身经历的演变。最终我们选择了 Functional Options——不是因为 Builder 不好,而是因为 Functional Options 更"Go"。当 Go 社区在 Dave Cheney 提出这个模式后迅速采用(grpc.Dial 的 grpc.WithInsecure() 等),说明这已经成了 Go 社区的事实标准。
工厂模式(Factory Pattern)在 Go 中的自然形式是 NewXxx 函数。
Go 标准库中到处都是这种模式:
http.NewRequest()— 创建一个 Requestbytes.NewReader()— 创建一个 Readermd5.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 的习惯:用函数类型替代接口,用可变参数替代链式调用。

一句话总结: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,调用方用 range 或 select 接收通知——这就是 Go 式的观察者模式。

迭代器模式(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 的关闭信号就是"没有更多元素了"。

之前我们讲了 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。

(实际项目中推荐使用 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}
}

组合替代继承。极小接口。NewXxx 工厂。依赖注入。
这不是"用了设计模式"——这是理解了 Go 的设计意图。
理解组合优于继承,策略模式和装饰器模式就是它的自然产物。你不需要翻 GOF 的书。
接口要小这件事搞清楚了,适配器模式和代理模式就成了日常工作。不用学"结构型模式"的分类。
接受了零值有用,sync.Once 和 Functional Options 就是顺手的选择。不用背"创建型模式"的定义。
用上了并发原生,channel 就替代了观察者和迭代器。不需要额外的框架。
把这四条哲学吃透,所谓的"设计模式"不过是在用 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