Skip to content

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/templatehtml/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 buildgo testgo fmtgo vetgo modgo run 全部内置,不需要 Maven/Gradle/Makefile 这堆东西。这是 Go 工程化的重要优势。

四、从 Java 到 Go 的思维转换

维度Java 思维Go 思维
类型关系继承层级组合 + 接口
错误处理try/catch 异常返回 error
并发线程 + 锁 / CompletableFuturegoroutine + channel
抽象接口 + 抽象类 + 实现类接口(小) + 结构体
构造构造器重载NewXxx + Functional Options
getter/setter必有直接字段访问
注解大量注解 + 反射代码生成 / 显式
依赖注入Spring 容器构造函数手动注入
测试JUnit + Mockitotesting + 接口 mock
项目结构Maven 多模块单仓库 + 多 package

转换的核心三句话:

  1. 少造抽象:先写能用,等需要替换再加接口。
  2. 错误就是值:习惯 if err != nil,不要怀念 try/catch。
  3. 并发是结构:用 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 里写代码时,遇到「要不要抽象」的纠结,可以用下面这个清单快速判断:

  1. 这段代码会变化吗? 不会 → 不要抽象。
  2. 有多种实现吗? 没有 → 不要抽象。
  3. 需要测试 mock 吗? 不需要 → 不要抽象。
  4. 接口能定义得多小? 越小越好,1~3 个方法最佳。
  5. 能用函数代替接口吗? 能(单方法、无状态)→ 用函数。
  6. 泛型能消除重复吗? 能 → 用泛型。
  7. 这个并发模式有标准库支持吗? 有(如 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 工程师,会写「看起来没什么设计模式」的代码——因为最合适的方案往往就是最朴素的那个。