第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 |
避免 util、common、helpers 大杂烩 | 按职责命名(httputil、randutil) |
| 一个包一个职责,避免循环依赖 | 循环依赖通常是抽象层缺失的信号 |
| 接口定义在消费方 | 见下文「接口」 |
命名
Go 的命名哲学是短而有信息量,作用域越小名字越短。
// 好:循环内用短名
for i, u := range users {
fmt.Println(i, u.Name)
}
// 好:导出标识符用完整词,文档化
func MaxConcurrentConnections() int { ... }
// 坏:作用域外也用单字母
func process(d Data) Result { ... } // d 是什么?
| 规则 | 示例 |
|---|---|
| 包名小写、单数、无下划线 | time、http、strconv |
| 导出标识符用驼峰 | ReadAll、io.EOF |
| 首字母缩写在导出时全大写、非导出全小写 | HTTPServer、httpClient |
| 接口名 -er 后缀,表行为 | Reader、Closer |
Getter 不加 Get 前缀 | p.Value() 不是 p.GetValue() |
布尔变量用 is/has/can | isReady、hasPermission |
| 常量用驼峰,非全大写 | math.Pi、os.O_RDONLY |
坑:包名会作为标识符前缀(
http.Server),所以别在包内又起Server、Client这种泛名再叫http.HTTPServer——冗余。用http.Server即可。
Context 使用规范
Context 是 Go 并发的“控制平面”,见 第13章 Context。规范:
- Context 作为函数第一个参数,命名为
ctx context.Context,不要放进 struct(除非该 struct 本身是一个会被 Cancel 的运行时对象)。 - 不要存业务数据:
ctx.WithValue只放请求级元数据(traceID、tenantID、鉴权身份),不放参数。 - 接受方必须尊重取消:长操作内要
select { case <-ctx.Done(): return ctx.Err(); case ... }。 - 不要把 context 存到 struct 字段长期持有,它是一次性请求生命周期。
- 不要用
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 决定 |
| 关联 traceID | 用 context 透传,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"`
}
推荐 envconfig、viper 或 koanf。要点:
- 敏感信息(密码、token)只从环境变量/secret manager 读,不进代码仓库、不进普通配置文件。
- 配置结构体显式定义,避免散落的
os.Getenv。 - 启动时 fail fast:缺少必填项直接退出,不要等到运行中才崩。
- 支持热更新(如 LogLevel)时,用原子变量或
sync.RWMutex保护,不要全局裸变量。
并发
并发是 Go 的强项也是事故高发区,见 第10章 Goroutine、第11章 Channel、第15章 sync 包。底线规范:
- 谁启动谁负责退出:每个 goroutine 都要有明确的终止路径(
ctx.Done()或关闭 channel),否则泄漏。 - 不要从 nil/已关闭 channel 接收除非有意用 nil channel 禁用 select 分支。
- 共享状态用 channel 或 sync,别用裸全局变量。
sync.Mutex不要值拷贝:用指针接收者持有,或用&sync.Mutex{}。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章 错误处理。核心:
- 错误是值,显式处理,不要
_ =吞掉。 fmt.Errorf("...: %w", err)包装,保留错误链,让errors.Is/As工作。- 错误信息面向人(含上下文),错误类型面向程序(用 sentinel 或自定义类型判断)。
- 不要返回裸
errors.New("failed"),要带是什么失败、哪一步、关键参数。 - 库不要打日志,只返回 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 火焰图找热点。