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

第33章 Go 项目最佳实践

本章基于 Go 1.26,把前面章节收敛成工程规范。原则是让行为、所有权、版本和失败方式都能从代码与自动化检查中读出来。

包组织

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

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

myapp/
  cmd/
    myapp/main.go        # 入口,只做装配
  internal/              # 仅供 myapp/ 目录树下的代码导入
    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/ 放私有代码编译器只允许 internal 父目录树下的代码导入;这是目录边界,不是 module 边界
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
单方法接口常按行为命名ReaderCloser;多方法接口按角色命名,不强求 -er
Getter 不加 Get 前缀p.Value() 不是 p.GetValue()
布尔变量用 is/has/canisReadyhasPermission
常量与变量一样使用 MixedCapsmath.Pitime.Second;协议名、系统常量等可有既定例外

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

Context 使用规范

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

  1. Context 作为函数第一个参数,命名为 ctx context.Context,不要塞进普通配置或依赖 struct。
  2. 不要存业务数据ctx.WithValue 只放请求级元数据(traceID、tenantID、鉴权身份),不放参数。
  3. 接受方必须尊重取消:长操作内要 select { case <-ctx.Done(): return ctx.Err(); case ... }
  4. 不要传 nil Context:暂时无法决定上游生命周期时显式使用 context.TODO()
  5. 不要用 context.Background() 忽略上层取消,除非是顶层入口或测试。
// 好
func FetchUser(ctx context.Context, url string, id int64) (*User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("build request for user %d: %w", id, err)
    }
    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 的强项也是事故高发区,见 第12章 Goroutine第13章 Channel第17章 sync 包。底线规范:

  1. 谁启动谁负责退出:每个 goroutine 都要有明确的终止路径(ctx.Done() 或关闭 channel),否则泄漏。
  2. 区分 nil 与已关闭 channel:直接收发 nil channel 会永久阻塞;在 select 中可故意把 channel 设为 nil 来禁用分支。已关闭 channel 仍可接收,缓冲耗尽后立即返回元素零值和 ok=false,循环接收时必须处理 ok,避免零值忙循环。
  3. 共享状态用 channel 或 sync,别用裸全局变量
  4. 同步原语首次使用后不得复制sync.Mutex 的零值即可用,通常直接作为 struct 字段并让相关方法使用指针接收者;不要为了“安全”无条件改成 *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()
}

版本边界:每轮独立循环变量语义按包所属模块的 go 版本生效;go 1.22 及以上使用新语义。维护更早语言版本的模块时仍需显式传参或写 v := v,升级则用测试和 vet 排查依赖旧行为的代码。

错误处理

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

  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

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

func BenchmarkProcess(b *testing.B) {
    data := prepare()
    for b.Loop() { // Go 1.24+
        benchmarkSink = process(data)
    }
}
go test -bench=. -benchmem -count=5 -cpu=1,4 ./...

这里的 -count=5 只是采样示例,不是固定标准;轮数应结合单轮耗时与环境噪声决定,并用相同条件下的 benchstat 比较结果。

要点:

实践说明
b.Loop()自动管理计时区间并降低循环体被优化掉的风险
-benchmem看每次分配 allocs/op
-count=N多轮降低噪声,用 benchstat 对比
避免编译器优化掉结果runtime.KeepAlive 或赋给包级变量
修改 GC 只做隔离诊断单独进程运行并恢复设置,不把关 GC 的结果当生产数据
先 profile 再优化不要盲猜热点

兼容 Go 1.23 及更早版本时使用 b.N;当前代码优先 b.Loop()。两种结果都要用 benchstat 做统计比较。

Profiling

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

package main

import (
    "errors"
    "log/slog"
    "net/http"
    "net/http/pprof"
    "time"
)

func startDebugServer() *http.Server {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /debug/pprof/", pprof.Index)
    mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
    mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
    mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
    mux.HandleFunc("POST /debug/pprof/symbol", pprof.Symbol)
    mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)

    server := &http.Server{
        Addr:              "127.0.0.1:6060",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        IdleTimeout:       60 * time.Second,
    }
    go func() {
        if err := server.ListenAndServe();
            err != nil && !errors.Is(err, http.ErrServerClosed) {
            slog.Error("pprof server stopped", "err", err)
        }
    }()
    return server
}

使用独立 mux 可避免把业务 DefaultServeMux 一并暴露;仅监听回环地址时,通过受控的端口转发访问。若必须远程监听,应放在管理网络并增加认证与访问控制。调用方保存返回的 Server,并在进程停机时调用 Shutdown

# CPU profile(30 秒采样)
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30

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

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

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

生产 Profiling 注意:

实践说明
CPU profile 使用有界窗口时长按事件频率、开销预算和问题持续时间决定,多次采样比较
go tool pprof -http=127.0.0.1:8080在本机浏览器查看调用图和火焰图,避免意外对外监听
理解 runtime.MemProfileRate当前默认平均每 512 KiB 分配采样一次;调小可增加样本,也会增加开销,仍不是精确追踪
goroutine profile 抓两次对比区分“正常多”与“持续增长(泄漏)”
线上开 pprof 要鉴权pprof 可泄漏内部状态,别裸暴露

本章小结

  • 包组织:internal/ 封装、cmd/ 只装配、按职责切包,避免 util/common
  • 命名:短而有信息量,包名作前缀避免冗余,接口按行为或角色命名。
  • Context:第一参数、不存业务数据、尊重取消、不进 struct。
  • 日志:结构化 + traceID,库不打日志只返回 error。
  • 配置:合并优先级必须确定且文档化,敏感信息不进仓库、启动时 fail fast。
  • 并发:谁启动谁退出、用 errgroup、避免循环变量陷阱。
  • 错误:%w 包装保链、面向人写信息、面向程序用 sentinel/类型。
  • 性能:先 Benchmark/Profiling 测量,再优化;-benchmem 看 allocs,pprof 火焰图找热点。
  • 测试与工具链:单元、race、fuzz、集成和版本矩阵分层执行,详见第27章
  • 可观测性与安全:控制 label 基数、保护 pprof、限制输入并持续运行 govulncheck,详见第28章