Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第27章 Go 项目最佳实践

本章把前面 26 章的底层原理收敛成可落地的工程规范。从包组织、命名到并发、错误处理、Benchmark/Profiling,覆盖一个生产级 Go 项目最该守住的底线。原则只有一条:写让三个月后的自己能一眼读懂的代码

包组织

Go 没有 Java 那样的层级包,扁平 + 按职责切分是主流。社区有两种成熟布局:

布局一:经典扁平(小/中项目)

myapp/
  cmd/
    myapp/main.go        # 入口,只做装配
  internal/              # 只能被本模块导入
    handler/
    service/
    repo/
    model/
  pkg/                   # 可被外部导入(谨慎使用)
  api/                   # OpenAPI / proto / CRD 定义
  go.mod

布局二:DDD/分层(中/大项目)

myapp/
  cmd/server/main.go
  internal/
    domain/       # 领域模型与核心业务规则(无外部依赖)
    application/  # 用例编排(调用 domain + port)
    infrastructure/ # 实现:db、mq、http client
    interfaces/   # 适配层:http/grpc handler
  pkg/

要点:

实践理由
internal/ 放私有代码编译器强制禁止外部模块导入,封装最稳
cmd/<name>/main.go 只做依赖装配业务逻辑不进 main,便于测试与多入口
包名与目录名一致、单数小写net/http 不是 nets/https
避免 utilcommonhelpers 大杂烩按职责命名(httputilrandutil
一个包一个职责,避免循环依赖循环依赖通常是抽象层缺失的信号
接口定义在消费方见下文「接口」

命名

Go 的命名哲学是短而有信息量,作用域越小名字越短。

// 好:循环内用短名
for i, u := range users {
    fmt.Println(i, u.Name)
}

// 好:导出标识符用完整词,文档化
func MaxConcurrentConnections() int { ... }

// 坏:作用域外也用单字母
func process(d Data) Result { ... } // d 是什么?
规则示例
包名小写、单数、无下划线timehttpstrconv
导出标识符用驼峰ReadAllio.EOF
首字母缩写在导出时全大写、非导出全小写HTTPServerhttpClient
接口名 -er 后缀,表行为ReaderCloser
Getter 不加 Get 前缀p.Value() 不是 p.GetValue()
布尔变量用 is/has/canisReadyhasPermission
常量用驼峰,非全大写math.Pios.O_RDONLY

坑:包名会作为标识符前缀(http.Server),所以别在包内又起 ServerClient 这种泛名再叫 http.HTTPServer——冗余。用 http.Server 即可。

Context 使用规范

Context 是 Go 并发的“控制平面”,见 第13章 Context。规范:

  1. Context 作为函数第一个参数,命名为 ctx context.Context,不要放进 struct(除非该 struct 本身是一个会被 Cancel 的运行时对象)。
  2. 不要存业务数据ctx.WithValue 只放请求级元数据(traceID、tenantID、鉴权身份),不放参数。
  3. 接受方必须尊重取消:长操作内要 select { case <-ctx.Done(): return ctx.Err(); case ... }
  4. 不要把 context 存到 struct 字段长期持有,它是一次性请求生命周期。
  5. 不要用 context.Background() 忽略上层取消,除非是顶层入口或测试。
// 好
func FetchUser(ctx context.Context, id int64) (*User, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetch user %d: %w", id, err)
    }
    defer resp.Body.Close()
    // ...
}

// 坏:忽略取消、放业务参数
func Fetch(id int64) (*User, error) { ... }
func process(ctx context.Context) {
    ctx = context.WithValue(ctx, "userID", 42) // 业务数据塞 ctx
}

日志

生产级日志三要素:结构化级别可控带 traceID 串联。推荐 log/slog(标准库 1.21+)或 zap/zerolog

import "log/slog"

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)

slog.Info("user created", "uid", u.ID, "name", u.Name, "traceID", traceID)
// {"time":"...","level":"INFO","msg":"user created","uid":1,"name":"x","traceID":"abc"}

规范:

实践说明
结构化(key-value)便于 ELK/Loki 索引与查询
不在循环里 Info高频路径用 Debug,或聚合后打一条
错误必须带上下文slog.Error("save failed", "err", err, "id", id)
不要 log.Fatal 在被引用库库只返回 error,退出由 main 决定
关联 traceIDcontext 透传,handler 从 ctx 取并写日志
日志 vs 错误错误返回给调用方,日志给运维;不要“返回了 error 还 log 一条”造成重复

配置

配置来源分层:环境变量 → 配置文件 → 命令行 flag,优先级从低到高。

type Config struct {
    HTTPAddr string `env:"HTTP_ADDR"   default:":8080"`
    DBDSN    string `env:"DB_DSN"      required:"true"`
    LogLevel string `env:"LOG_LEVEL"   default:"info"`
}

推荐 envconfigviperkoanf。要点:

  • 敏感信息(密码、token)只从环境变量/secret manager 读,不进代码仓库、不进普通配置文件
  • 配置结构体显式定义,避免散落的 os.Getenv
  • 启动时 fail fast:缺少必填项直接退出,不要等到运行中才崩。
  • 支持热更新(如 LogLevel)时,用原子变量或 sync.RWMutex 保护,不要全局裸变量。

并发

并发是 Go 的强项也是事故高发区,见 第10章 Goroutine第11章 Channel第15章 sync 包。底线规范:

  1. 谁启动谁负责退出:每个 goroutine 都要有明确的终止路径(ctx.Done() 或关闭 channel),否则泄漏。
  2. 不要从 nil/已关闭 channel 接收除非有意用 nil channel 禁用 select 分支。
  3. 共享状态用 channel 或 sync,别用裸全局变量
  4. sync.Mutex 不要值拷贝:用指针接收者持有,或用 &sync.Mutex{}
  5. errgroup 管理一组 goroutine + error,比手写 WaitGroup + error chan 干净。
import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([][]byte, len(urls))
    for i, u := range urls {
        i, u := i, u // 捕获循环变量(1.22 前必需)
        g.Go(func() error {
            data, err := fetch(ctx, u)
            if err != nil {
                return fmt.Errorf("fetch %s: %w", u, err)
            }
            results[i] = data
            return nil
        })
    }
    return results, g.Wait()
}

坑:for _, v := range xs { go func(){ use(v) }() } 在 Go 1.22 前会共享循环变量,所有 goroutine 看到同一个 v;1.22+ 每轮迭代新建变量。

错误处理

详见 第20章 错误处理。核心:

  1. 错误是值,显式处理,不要 _ = 吞掉。
  2. fmt.Errorf("...: %w", err) 包装,保留错误链,让 errors.Is/As 工作。
  3. 错误信息面向人(含上下文),错误类型面向程序(用 sentinel 或自定义类型判断)。
  4. 不要返回裸 errors.New("failed"),要带是什么失败、哪一步、关键参数。
  5. 库不要打日志,只返回 error;最外层决定日志/告警。
// 自定义错误类型 + sentinel
var ErrUserNotFound = errors.New("user not found")

type ValidationError struct{ Field, Reason string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }

func (r *Repo) Get(ctx context.Context, id int64) (*User, error) {
    u, err := r.db.QueryUser(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("repo get user %d: %w", id, err)
    }
    if u == nil {
        return nil, ErrUserNotFound
    }
    return u, nil
}

// 调用方
u, err := r.Get(ctx, id)
if errors.Is(err, ErrUserNotFound) { ... }
var ve *ValidationError
if errors.As(err, &ve) { ... }

Benchmark

性能优化先测量,见 第21章 性能优化。Benchmark 规范:

func BenchmarkProcess(b *testing.B) {
    data := prepare()          // 准备放循环外
    b.ResetTimer()             // 排除准备时间
    for i := 0; i < b.N; i++ {
        _ = process(data)
    }
}
go test -bench=. -benchmem -count=5 -cpu=1,4 ./...

要点:

实践说明
b.ResetTimer()排除数据准备时间
-benchmem看每次分配 allocs/op
-count=N多轮降低噪声,用 benchstat 对比
避免编译器优化掉结果runtime.KeepAlive 或赋给包级变量
关闭 GC 对比纯算法debug.SetGCPercent(-1)
先 profile 再优化不要盲猜热点

坑:b.N 是由 testing 框架自适应调整的,不要在 bench 里硬编码循环次数。

Profiling

定位生产问题靠 pprof,见 第21章 性能优化

import _ "net/http/pprof"

func main() {
    go func() { http.ListenAndServe(":6060", nil) }()
    // ...
}
# CPU profile(30 秒采样)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# 堆
go tool pprof http://localhost:6060/debug/pprof/heap

# goroutine(排查泄漏)
go tool pprof http://localhost:6060/debug/pprof/goroutine

# 在 pprof 交互里
(pprof) top10
(pprof) list <func>
(pprof) web            # 调用图(需 graphviz)

生产 Profiling 注意:

实践说明
CPU profile 采样 30s 起步太短噪声大
go tool pprof -http=:8080浏览器火焰图最直观
堆 profile 需 runtime.MemProfileRate默认每 512KB 采样一次,精确需调小
goroutine profile 抓两次对比区分“正常多”与“持续增长(泄漏)”
线上开 pprof 要鉴权pprof 可泄漏内部状态,别裸暴露

本章小结

  • 包组织:internal/ 封装、cmd/ 只装配、按职责切包,避免 util/common
  • 命名:短而有信息量,包名作前缀避免冗余,接口 -er 后缀。
  • Context:第一参数、不存业务数据、尊重取消、不进 struct。
  • 日志:结构化 + traceID,库不打日志只返回 error。
  • 配置:环境变量优先、敏感信息不进仓库、fail fast。
  • 并发:谁启动谁退出、用 errgroup、避免循环变量陷阱。
  • 错误:%w 包装保链、面向人写信息、面向程序用 sentinel/类型。
  • 性能:先 Benchmark/Profiling 测量,再优化;-benchmem 看 allocs,pprof 火焰图找热点。