Appearance
Go 惯用模式与反模式
前面 11 章我们讲了 GoF 经典设计模式在 Go 中的实现。但 Go 社区真正高频使用的,并不是 23 种模式里的某一个,而是一组「Go 特有的惯用模式」:Functional Options、Middleware、Pipeline、Fan-out/Fan-in、Worker Pool、Context、Error Wrapping、表驱动测试。这些模式不在 GoF 书里,却是 Go 工程师的「日常武器」。
本章我们把它们汇总讲一遍,同时列出常见的反模式(Anti-patterns),帮助从 Java/C++ 转过来的同学避开陷阱。
一、Go 特有的惯用模式
1. Functional Options(函数式选项)
第 4 章详细讲过,这里给个最小例子复习:
go
package main
import (
"fmt"
"time"
)
type Server struct {
port int
timeout time.Duration
}
type Option func(*Server)
func NewServer(opts ...Option) *Server {
s := &Server{port: 8080, timeout: 30 * time.Second}
for _, o := range opts {
o(s)
}
return s
}
func WithPort(p int) Option { return func(s *Server) { s.port = p } }
func WithTimeout(t time.Duration) Option { return func(s *Server) { s.timeout = t } }
func main() {
s := NewServer(WithPort(9090), WithTimeout(5*time.Second))
fmt.Printf("%+v\n", s)
}适用场景:构造对象有较多可选参数。优点:API 稳定、默认值集中、可组合。
2. Middleware(中间件/装饰器)
第 6 章详细讲过。Go HTTP 中间件签名 func(http.Handler) http.Handler 是装饰器模式的体现:
go
package main
import (
"fmt"
"net/http"
)
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("[LOG]", r.URL.Path)
next.ServeHTTP(w, r)
})
}
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func main() {
final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
_ = Chain(final, Logger)
fmt.Println("中间件链组装完成")
}3. Pipeline(管道)
把一个大任务拆成多个阶段,每个阶段的输出是下一个阶段的输入,用 channel 串联:
go
package main
import "fmt"
func stage1(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n + 1
}
}()
return out
}
func stage2(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * 2
}
}()
return out
}
func stage3(in <-chan int) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for n := range in {
out <- fmt.Sprintf("result=%d", n)
}
}()
return out
}
func main() {
in := make(chan int)
go func() {
for i := 0; i < 5; i++ {
in <- i
}
close(in)
}()
for r := range stage3(stage2(stage1(in))) {
fmt.Println(r)
}
}Pipeline 让每个阶段独立、可复用、可并行。这是 Unix 管道思想在 Go 中的体现。
4. Fan-out/Fan-in
Fan-out:把一个 channel 的数据分发给多个 goroutine 并行处理。Fan-in:把多个 goroutine 的结果汇聚到一个 channel。
go
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
fmt.Printf("worker %d 处理 %d\n", id, j)
results <- j * j
}
}
func main() {
jobs := make(chan int, 10)
results := make(chan int, 10)
// Fan-out:启动 3 个 worker
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// 投递任务
for j := 1; j <= 6; j++ {
jobs <- j
}
close(jobs)
// 等所有 worker 完成,关闭 results
go func() {
wg.Wait()
close(results)
}()
// Fan-in:从 results 汇总
sum := 0
for r := range results {
sum += r
}
fmt.Println("总和:", sum)
}Fan-out/Fan-in 是 Go 并发最常用的并行模式,适合「无依赖的任务」并行处理。
5. Worker Pool
固定数量的 worker 从任务队列取任务执行,控制并发度,避免 goroutine 爆炸:
go
package main
import (
"fmt"
"sync"
"time"
)
type Task struct {
ID int
}
func worker(id int, tasks <-chan Task, wg *sync.WaitGroup) {
defer wg.Done()
for t := range tasks {
fmt.Printf("worker %d 处理任务 %d\n", id, t.ID)
time.Sleep(50 * time.Millisecond)
}
}
func main() {
tasks := make(chan Task, 100)
var wg sync.WaitGroup
// 启动固定数量 worker
const numWorkers = 3
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, tasks, &wg)
}
// 投递 10 个任务
for i := 1; i <= 10; i++ {
tasks <- Task{ID: i}
}
close(tasks)
wg.Wait()
fmt.Println("所有任务完成")
}Worker Pool 控制并发上限,保护下游资源(数据库、第三方 API),是生产环境的标配。
6. Context 传播
context.Context 用于在 goroutine 间传递取消信号、超时、请求级数据。Go 的每个长任务函数都应该接收 context 作为第一个参数:
go
package main
import (
"context"
"fmt"
"time"
)
func doWork(ctx context.Context, name string) error {
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return fmt.Errorf("%s 被取消: %w", name, ctx.Err())
default:
}
fmt.Printf("%s: 第 %d 步\n", name, i)
time.Sleep(50 * time.Millisecond)
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond)
defer cancel()
if err := doWork(ctx, "任务A"); err != nil {
fmt.Println("最终:", err)
}
}规则:
context.Context作为函数第一个参数,命名为ctx。- 不要把 context 存到 struct 里(除非该 struct 是请求级别的)。
- 取消信号要向下传递,不要忽略
ctx.Done()。
7. Error Wrapping(错误包装)
Go 1.13+ 引入 errors.Is / errors.As / fmt.Errorf("...: %w", err),让错误可以包装、解包:
go
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func findUser(id string) error {
return fmt.Errorf("查询用户 %s 失败: %w", id, ErrNotFound)
}
func main() {
err := findUser("001")
if errors.Is(err, ErrNotFound) {
fmt.Println("捕获到特定错误: 用户不存在")
}
fmt.Println("完整错误链:", err)
}规则:
- 用
%w包装错误,保留错误链。 - 用
errors.Is判断是否是特定错误(值比较)。 - 用
errors.As提取特定类型的错误。 - 不要用
errors.New(err.Error())这种「断链」写法。 - 错误应在最底层包装一次,避免每层都包装导致日志重复。
8. 表驱动测试
把测试用例组织成表格,循环执行,避免每个用例写一段重复代码:
go
package main
import "fmt"
func Add(a, b int) int { return a + b }
func main() {
tests := []struct {
name string
a, b int
expected int
}{
{"正数相加", 1, 2, 3},
{"负数相加", -1, -2, -3},
{"零", 0, 0, 0},
{"混合", -1, 1, 0},
}
for _, tt := range tests {
got := Add(tt.a, tt.b)
status := "PASS"
if got != tt.expected {
status = "FAIL"
}
fmt.Printf("[%s] %s: Add(%d,%d)=%d, 期望 %d\n",
status, tt.name, tt.a, tt.b, got, tt.expected)
}
}表驱动测试是 Go 测试的标准写法,配合 testing 包的 t.Run(tt.name, func(t *testing.T){...}) 可以让每个子测试独立运行、独立报告。
9. 关于 Go 模板语法的提醒
在 Go 项目中偶尔会用到 text/template 或 html/template,它们使用一对双花括号(左双花括号加右双花括号)作为定界符。在 VitePress 等 Markdown 文档中,非代码块区域出现这些字符需要转义,否则会被解析器误判。例如描述模板语法时,应写成 \{\{ .Field \}\} 而不是直接写原始定界符加 .Field。代码块内的内容不受影响。这个细节在写文档时要特别注意。
二、Go 反模式(Anti-patterns)
反模式是「看起来能用,但会带来麻烦」的写法。下面列出从 Java/C++ 转 Go 时常踩的坑。
1. 过度使用 interface{}
interface{}(1.18 后可写 any)是 Go 的逃生舱,但滥用会丢失类型安全:
go
package main
import "fmt"
// 反模式:用 interface{} 当通用容器
type BadStack struct {
data []interface{}
}
func (s *BadStack) Push(v interface{}) { s.data = append(s.data, v) }
func (s *BadStack) Pop() interface{} {
if len(s.data) == 0 {
return nil
}
v := s.data[len(s.data)-1]
s.data = s.data[:len(s.data)-1]
return v
}
// 推荐:用泛型
type GoodStack[T any] struct {
data []T
}
func (s *GoodStack[T]) Push(v T) { s.data = append(s.data, v) }
func (s *GoodStack[T]) Pop() (T, bool) {
var zero T
if len(s.data) == 0 {
return zero, false
}
v := s.data[len(s.data)-1]
s.data = s.data[:len(s.data)-1]
return v, true
}
func main() {
bad := &BadStack{}
bad.Push(1)
bad.Push("hello") // 类型混乱
n := bad.Pop().(int) // 必须类型断言,运行时可能 panic
fmt.Println(n)
good := &GoodStack[int]{}
good.Push(1)
good.Push(2)
v, _ := good.Pop() // 类型安全
fmt.Println(v)
}经验:能用泛型就用泛型,迫不得已才用 any,且在使用处尽快类型断言或转成具体接口。
2. 不必要的抽象
Java 习惯「每个实现配一个接口」,Go 不需要。Go 的接口应在消费者处定义,且越小越好。
go
package main
import "fmt"
// 反模式:给每个服务都定义 IXxx 接口
type IUserService interface {
GetUser(id string) string
}
type UserService struct{}
func (UserService) GetUser(id string) string { return "user-" + id }
// 推荐:在调用方按需定义小接口
type UserGetter interface {
GetUser(id string) string
}
func printUser(g UserGetter, id string) {
fmt.Println(g.GetUser(id))
}
func main() {
printUser(UserService{}, "001")
}规则:先写实现,等真的需要替换/mock 时再提接口。Go 不为「未来可能」提前抽象。
3. Java 式的 getter/setter
Java 习惯 private 字段 + getXxx/setXxx。Go 不需要:
go
package main
import "fmt"
// 反模式:Java 风格
type BadUser struct {
name string
}
func (u *BadUser) GetName() string { return u.name }
func (u *BadUser) SetName(n string) { u.name = n }
// 推荐:直接用导出字段,需要校验时才用方法
type GoodUser struct {
Name string
}
// 需要校验的场景:方法名不带 Get
type Age struct {
value int
}
func (a *Age) Set(v int) error {
if v < 0 || v > 200 {
return fmt.Errorf("非法年龄")
}
a.value = v
return nil
}
func (a *Age) Value() int { return a.value }
func main() {
u := GoodUser{Name: "Alice"}
fmt.Println(u.Name)
bad := &BadUser{}
bad.SetName("Bob")
fmt.Println(bad.GetName())
}Go 命名约定:
- 字段直接导出(首字母大写)即可被外部访问,不需要 getter。
- 需要 getter 时,方法名就是字段名(如
Name()而不是GetName())。 - setter 才带
Set前缀(如SetName)。
4. 嵌入滥用
嵌入不是继承,不要建深层嵌套链:
go
package main
import "fmt"
// 反模式:多层嵌入模拟继承
type Base struct{}
func (Base) Hello() { fmt.Println("hello") }
type Mid struct{ Base }
type Top struct{ Mid }
// 推荐:扁平组合,明确依赖
type Service struct {
base Base // 显式字段
}
func (s *Service) Work() {
s.base.Hello()
}
func main() {
t := Top{}
t.Hello()
s := &Service{}
s.Work()
}嵌入适合「混入能力」(如嵌入 sync.Mutex 让结构体自带锁方法),不适合建层级。超过 2 层嵌入基本就是滥用。
5. init 滥用
init 应该只用于「注册」这类纯副作用操作,不要做复杂逻辑:
go
package main
import "fmt"
// 反模式:init 里读配置、连数据库
var config map[string]string
func init() {
// 假装读配置
config = map[string]string{"env": "prod"}
fmt.Println("init 读配置(不推荐)")
}
// 推荐:用显式函数,可返回 error
func LoadConfig() (map[string]string, error) {
return map[string]string{"env": "prod"}, nil
}
func main() {
// 显式调用
cfg, _ := LoadConfig()
fmt.Println(cfg)
}init 的坏处:无法传参、无法返回 error、无法跳过、执行顺序隐晦。复杂初始化应该用显式函数,在 main 里调用。
6. panic 滥用
panic 不是异常,只用于「不可恢复的编程错误」:
go
package main
import "fmt"
// 反模式:用 panic 当异常
func divide(a, b int) int {
if b == 0 {
panic("除零")
}
return a / b
}
// 推荐:返回 error
func divideSafe(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("除零错误")
}
return a / b, nil
}
func main() {
// 不推荐
func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获 panic:", r)
}
}()
divide(1, 0)
}()
// 推荐
if v, err := divideSafe(1, 0); err != nil {
fmt.Println("错误:", err)
} else {
fmt.Println("结果:", v)
}
}panic 的合法场景:
- 程序启动时配置非法(如
regexp.MustCompile解析失败)。 - 包内部不变量被破坏(如 switch 的 default 分支不该到达)。
- 第三方库的 nil 解引用等「绝不该发生」的情况。
业务错误一律用 error,不要用 panic + recover 模拟 try/catch。
三、Go 社区的设计哲学
1. 简单优于复杂
Go 故意没有泛型(直到 1.18)、没有继承、没有运算符重载、没有宏。这不是缺陷,而是选择:用更少的特性换更高的可读性、更低的入门门槛、更稳定的代码。
- 一个函数能搞定的,不要造一个类。
- 一个
if能搞定的,不要造一个策略模式。 - 一个 struct 能搞定的,不要造一个继承层级。
2. 显式优于隐式
- 错误显式返回,不用异常。
- 类型转换显式写,不隐式转换。
- 接口实现不需要
implements关键字,但「是否实现」是显式可见的(编译期检查)。 - goroutine 调度由运行时管,但 channel 通信是显式的。
- context 显式传递,不用 ThreadLocal。
显式的代价是代码略啰嗦,收益是「读代码就能理解发生了什么」,没有魔法。
3. 组合优于继承
这条我们已经强调多次。Go 没有继承,靠组合、嵌入、接口组装复杂系统。这让代码更扁平、更易测试、更易理解。
4. 约定优于配置
Go 用 go fmt 统一格式,用 gofmt 消灭风格争论;用包名作为命名空间,不需要 import xxx as yyy;用首字母大小写控制可见性,不需要 public/private 关键字。这些约定减少了配置成本。
5. 工具链一体化
go build、go test、go fmt、go vet、go mod、go run 全部内置,不需要 Maven/Gradle/Makefile 这堆东西。这是 Go 工程化的重要优势。
四、从 Java 到 Go 的思维转换
| 维度 | Java 思维 | Go 思维 |
|---|---|---|
| 类型关系 | 继承层级 | 组合 + 接口 |
| 错误处理 | try/catch 异常 | 返回 error |
| 并发 | 线程 + 锁 / CompletableFuture | goroutine + channel |
| 抽象 | 接口 + 抽象类 + 实现类 | 接口(小) + 结构体 |
| 构造 | 构造器重载 | NewXxx + Functional Options |
| getter/setter | 必有 | 直接字段访问 |
| 注解 | 大量注解 + 反射 | 代码生成 / 显式 |
| 依赖注入 | Spring 容器 | 构造函数手动注入 |
| 测试 | JUnit + Mockito | testing + 接口 mock |
| 项目结构 | Maven 多模块 | 单仓库 + 多 package |
转换的核心三句话:
- 少造抽象:先写能用,等需要替换再加接口。
- 错误就是值:习惯
if err != nil,不要怀念 try/catch。 - 并发是结构:用 channel 组织并发,不要用共享内存 + 锁硬刚。
五、一个完整的对照示例
下面用同一个需求(HTTP 客户端带重试和日志),对比 Java 风格和 Go 风格:
go
package main
import (
"errors"
"fmt"
"time"
)
// === Java 风格:过度抽象 ===
type HTTPExecutor interface {
Do(url string) (string, error)
}
type RetryExecutor struct {
inner HTTPExecutor
maxRetry int
}
func (r *RetryExecutor) Do(url string) (string, error) {
var lastErr error
for i := 0; i <= r.maxRetry; i++ {
result, err := r.inner.Do(url)
if err == nil {
return result, nil
}
lastErr = err
}
return "", fmt.Errorf("重试 %d 次后仍失败: %w", r.maxRetry, lastErr)
}
type LogExecutor struct {
inner HTTPExecutor
}
func (l *LogExecutor) Do(url string) (string, error) {
fmt.Printf("[LOG] 请求 %s\n", url)
return l.inner.Do(url)
}
type RealExecutor struct{}
func (RealExecutor) Do(url string) (string, error) {
return "response from " + url, nil
}
// === Go 风格:函数 + 装饰 ===
type DoFunc func(url string) (string, error)
func WithRetry(maxRetry int, f DoFunc) DoFunc {
return func(url string) (string, error) {
var lastErr error
for i := 0; i <= maxRetry; i++ {
result, err := f(url)
if err == nil {
return result, nil
}
lastErr = err
}
return "", fmt.Errorf("重试失败: %w", lastErr)
}
}
func WithLog(f DoFunc) DoFunc {
return func(url string) (string, error) {
fmt.Printf("[LOG] 请求 %s\n", url)
return f(url)
}
}
func main() {
// Java 风格
javaExec := &LogExecutor{
inner: &RetryExecutor{
inner: RealExecutor{}, maxRetry: 3,
},
}
r, _ := javaExec.Do("http://a.com")
fmt.Println("Java 风格:", r)
// Go 风格
doReal := DoFunc(func(url string) (string, error) {
if url == "http://bad.com" {
return "", errors.New("网络错误")
}
return "response from " + url, nil
})
goExec := WithLog(WithRetry(3, doReal))
r2, _ := goExec("http://b.com")
fmt.Println("Go 风格:", r2)
_ = time.Second
}两种风格都「对」,但 Go 风格用函数装饰器,代码量少一半,且更容易组合。Java 风格在「策略需要多方法」时才占优。
六、判断要不要用模式的清单
在 Go 里写代码时,遇到「要不要抽象」的纠结,可以用下面这个清单快速判断:
- 这段代码会变化吗? 不会 → 不要抽象。
- 有多种实现吗? 没有 → 不要抽象。
- 需要测试 mock 吗? 不需要 → 不要抽象。
- 接口能定义得多小? 越小越好,1~3 个方法最佳。
- 能用函数代替接口吗? 能(单方法、无状态)→ 用函数。
- 泛型能消除重复吗? 能 → 用泛型。
- 这个并发模式有标准库支持吗? 有(如
errgroup)→ 用标准库。
记住 Go 社区的口头禅:「不要为了想象中的未来而提前抽象。」(Don't abstract for an imaginary future.)
七、小结
- Go 特有的惯用模式比 GoF 23 种更常用:Functional Options、Middleware、Pipeline、Fan-out/Fan-in、Worker Pool、Context、Error Wrapping、表驱动测试。
- Functional Options 处理可选参数;Middleware 实现装饰器/职责链;Pipeline + Fan-out/Fan-in + Worker Pool 是并发的三大件;Context 是取消和超时的标准;Error Wrapping 是错误链的基础;表驱动测试是测试的标准写法。
- 常见反模式:过度使用
interface{}、不必要的抽象、Java 式 getter/setter、嵌入滥用、init 滥用、panic 滥用。 - Go 设计哲学:简单优于复杂、显式优于隐式、组合优于继承、约定优于配置、工具链一体化。
- 从 Java 转 Go 的核心三句话:少造抽象、错误就是值、并发是结构。
- 判断要不要抽象的清单:会变吗?有多种实现吗?需要 mock 吗?接口能多小?能用函数吗?能用泛型吗?有标准库吗?
- 写 Go 代码的最高境界不是「用了多少模式」,而是「删掉了多少不必要的抽象」。
本系列到此结束。设计模式是工具,不是目标。真正成熟的 Go 工程师,会写「看起来没什么设计模式」的代码——因为最合适的方案往往就是最朴素的那个。