Uber Go 风格指南
[!NOTE] 本指南为 Uber Go Style Guide 的翻译,由 GPT-5 生成。
“风格”是管理我们代码的约定。术语“风格”有些名不副实,因为这些约定涵盖的不仅仅是源文件格式——gofmt 已经为我们处理了那部分。
本指南的目标是通过详细描述在 Uber 编写 Go 代码的注意事项,来管理复杂性。这些规则的存在是为了在仍然允许工程师高效使用 Go 语言特性的同时,使代码库保持可维护。
本指南最初由 Prashant Varanasi 和 Simon Newton 创建,用于帮助同事快速上手 Go。多年来根据社区反馈不断完善。
本文档记录了我们在 Uber 所遵循的 Go 代码惯例。其中很多是 Go 的通用指南,另外一些在外部资源基础上扩展:
我们力求示例代码适配 Go 发布版的最近两个次要版本。
所有代码在通过 golint 与 go vet 时应无错误。我们建议在编辑器里:
- 保存时运行
goimports - 运行
golint和go vet检查错误
编辑器对 Go 工具的支持见: https://go.dev/wiki/IDEsAndTextEditorPlugins
指向接口的指针
Section titled “指向接口的指针”几乎不需要“接口的指针”。应当按值传递接口——其底层数据依然可以是指针。
一个接口底层是两个字段:
- 指向某些类型特定信息的指针,可视作“类型”。
- 数据指针。如果存放的是指针,就直接保存;如果是值,就保存该值的指针。
如果你希望接口方法能修改底层数据,必须使用指针(即方法接收者为指针类型)。
编译期校验接口实现
Section titled “编译期校验接口实现”在合适的地方于编译期验证接口符合性。包括:
- 作为 API 合约一部分、要求实现特定接口的导出类型
- 作为实现相同接口的一组类型(导出或未导出)
- 以及违反接口会破坏用户的其它情况
| 反例 | 正例 |
|---|---|
|
|
语句 var _ http.Handler = (*Handler)(nil) 会在 *Handler 不再满足 http.Handler 接口时编译失败。
赋值右侧应为被断言类型的零值。对于指针类型(如 *Handler)、切片和 map 是 nil,对于结构体是空结构体。
type LogHandler struct { h http.Handler log *zap.Logger}
var _ http.Handler = LogHandler{}
func (h LogHandler) ServeHTTP( w http.ResponseWriter, r *http.Request,) { // ...}接收者与接口
Section titled “接收者与接口”值接收者的方法既可在值上调用,也可在指针上调用。指针接收者的方法只能在指针或可寻址值上调用。
例如:
type S struct { data string}
func (s S) Read() string { return s.data}
func (s *S) Write(str string) { s.data = str}
// 无法获取存于 map 中值的指针,因为它们不可寻址。sVals := map[int]S{1: {"A"}}
// Read 是值接收者,不要求可寻址,故可调用。sVals[1].Read()
// Write 是指针接收者,对 map 中的值无法取地址,不能调用。// sVals[1].Write("test")
sPtrs := map[int]*S{1: {"A"}}
// 若 map 存放指针,两者都可调用,因为指针本身可寻址。sPtrs[1].Read()sPtrs[1].Write("test")同样,即便方法是值接收者,接口也可以由指针类型实现。
type F interface { f()}
type S1 struct{}
func (s S1) f() {}
type S2 struct{}
func (s *S2) f() {}
s1Val := S1{}s1Ptr := &S1{}s2Val := S2{}s2Ptr := &S2{}
var i Fi = s1Vali = s1Ptri = s2Ptr
// 下行不编译:s2Val 是值,而 f 没有值接收者。// i = s2Val详见 Effective Go 的指针与值。
互斥锁零值可用
Section titled “互斥锁零值可用”sync.Mutex 和 sync.RWMutex 的零值是有效的,几乎不需要对 mutex 取指针。
| 反例 | 正例 |
|---|---|
|
|
如果结构体通过指针使用,那么其 mutex 字段也应为非指针字段。不要在结构体中嵌入 mutex,即便结构体未导出。
| 反例 | 正例 |
|---|---|
|
|
|
|
mutex 及其方法作为实现细节被隐藏,不暴露给 |
在边界复制切片和映射
Section titled “在边界复制切片和映射”切片与 map 内部持有指向底层数据的指针,在需要复制时要留心。
接收切片与 map
Section titled “接收切片与 map”若你保存了传入切片或 map 的引用,调用方可以修改它们。
| 反例 | 正例 |
|---|---|
|
|
返回切片与 map
Section titled “返回切片与 map”同样,注意不要通过返回内部切片或 map 暴露内部状态,从而允许外部修改。
| 反例 | 正例 |
|---|---|
|
|
用 defer 清理
Section titled “用 defer 清理”使用 defer 清理资源,例如文件和锁。
| 反例 | 正例 |
|---|---|
|
|
defer 的开销极小,仅当你能证明函数执行时间在纳秒级时才考虑避免。defer 带来的可读性收益远大于微小的成本,尤其在更大函数中,其他计算相较于 defer 更显著。
通道容量为 1 或 0
Section titled “通道容量为 1 或 0”通道通常使用容量为 1 或无缓冲(默认 0)。其它容量必须经过严格审视:容量如何确定、在负载下如何防止写端阻塞、填满会如何。
| 反例 | 正例 |
|---|---|
|
|
枚举从 1 开始
Section titled “枚举从 1 开始”在 Go 中,通常用自定义类型搭配 iota 的 const 组表示枚举。由于变量默认值为 0,通常应从非零开始。
| 反例 | 正例 |
|---|---|
|
|
当 0 值是理想的默认行为时例外:
type LogOutput int
const ( LogToStdout LogOutput = iota LogToFile LogToRemote)
// LogToStdout=0, LogToFile=1, LogToRemote=2使用 "time" 处理时间
Section titled “使用 "time" 处理时间”时间很复杂。常见但错误的假设包括:
- 一天有 24 小时
- 一小时有 60 分钟
- 一周有 7 天
- 一年有 365 天
- 还有很多
例如假设 1 意味着给某一时刻加 24 小时并不总是落在下一个日历日。
因此处理时间时,总是使用 "time" 包,它能更安全、更准确地应对这些错误假设。
使用 time.Time 表示时间点
Section titled “使用 time.Time 表示时间点”处理时间点时用 time.Time,比较、加减时使用其方法。
| 反例 | 正例 |
|---|---|
|
|
使用 time.Duration 表示时间段
Section titled “使用 time.Duration 表示时间段”处理时间段时用 time.Duration。
| 反例 | 正例 |
|---|---|
|
|
回到“给某一时刻加 24 小时”的例子,选择的方法取决于意图:如果想在下一个日历日的同一时间点,用 Time.AddDate;若想严格保证前后相差 24 小时的时间点,用 Time.Add。
newDay := t.AddDate(0 /* years */, 0 /* months */, 1 /* days */)maybeNewDay := t.Add(24 * time.Hour)在与外部系统交互时使用 time.Time 与 time.Duration
Section titled “在与外部系统交互时使用 time.Time 与 time.Duration”尽可能在与外部系统交互时使用 time.Time 与 time.Duration。例如:
- 命令行参数:
flag通过time.ParseDuration支持time.Duration - JSON:
encoding/json通过UnmarshalJSON以 RFC 3339 字符串编码time.Time - SQL:若驱动支持,
database/sql可在DATETIME/TIMESTAMP与time.Time间互转 - YAML:
gopkg.in/yaml.v2将time.Time作为 RFC 3339 字符串,time.Duration通过time.ParseDuration
当无法使用 time.Duration 时,使用 int 或 float64 并在字段名中包含单位。
例如,encoding/json 不支持 time.Duration,则在字段名中包含单位。
| 反例 | 正例 |
|---|---|
|
|
当无法使用 time.Time 时,除非另有约定,使用 string 并按 RFC 3339 格式化。该格式被 Time.UnmarshalText 默认使用,也可通过 time.RFC3339 在 Time.Format 与 time.Parse 中使用。
尽管通常问题不大,但请注意 "time" 包不支持解析包含闰秒的时间戳(8728),也不在计算中考虑闰秒(15190)。比较两个时刻的差值不包含其间发生的闰秒。
声明错误有多种方式。选择前请考虑:
- 调用方是否需要匹配错误并据此处理?
若是,则需要通过声明包级错误变量或自定义类型来支持
errors.Is或errors.As。 - 错误消息是静态字符串还是需要上下文的动态字符串?
前者用
errors.New,后者用fmt.Errorf或自定义错误类型。 - 我们是否在传播下游函数返回的新错误? 若是,参见错误包装。
| 需要匹配? | 错误消息 | 指南 |
|---|---|---|
| 否 | 静态 | errors.New |
| 否 | 动态 | fmt.Errorf |
| 是 | 静态 | 包级 var + errors.New |
| 是 | 动态 | 自定义 error 类型 |
例如,静态字符串错误使用 errors.New。若调用方需要匹配并处理,导出该错误变量以支持 errors.Is。
| 无需匹配 | 需要匹配 |
|---|---|
|
|
动态字符串错误:若调用方无需匹配,用 fmt.Errorf;若需要匹配,使用自定义 error 类型。
| 无需匹配 | 需要匹配 |
|---|---|
|
|
注意:导出错误变量或类型会成为包的公共 API 一部分。
当调用失败时传播错误主要有三种方式:
- 原样返回底层错误
- 使用带
%w的fmt.Errorf添加上下文 - 使用带
%v的fmt.Errorf添加上下文
若没有额外上下文,直接返回原始错误:保持原始错误类型与消息,适用于错误本身已足够定位来源的情况。
否则,应尽可能为错误消息添加上下文。相比模糊的“connection refused”,更希望得到“调用服务 foo:connection refused”。
使用 fmt.Errorf 添加上下文,根据是否允许调用方匹配底层原因选择 %w 或 %v:
- 若调用方应能访问底层错误,用
%w。这是多数包装错误的默认选择。但注意调用方可能依赖此行为;对于包装已知var或类型的情况,将其作为函数契约进行文档化并测试。 - 若需要隐藏底层错误,用
%v。调用方无法匹配;未来需要时可改为%w。
添加上下文时保持简洁,避免“failed to …”之类的赘述,否则随着调用栈向上传播会堆叠重复:
| 反例 | 正例 |
|---|---|
|
|
|
|
但一旦错误发送到其它系统,应明确这是错误消息(如使用 err 标签或日志中以“Failed”前缀)。
另见 Don’t just check errors, handle them gracefully。
包级全局错误值根据导出性使用 Err 或 err 前缀。该建议优先于未导出的全局以 _ 前缀.
var ( // 下两者导出,便于包使用者用 errors.Is 匹配 ErrBrokenLink = errors.New("link is broken") ErrCouldNotOpen = errors.New("could not open")
// 未导出错误,不作为公共 API; // 包内依然可用 errors.Is 匹配 errNotFound = errors.New("not found"))自定义错误类型使用 Error 作为后缀。
// 同样,导出该错误类型,便于包使用者用 errors.As 匹配。type NotFoundError struct { File string}
func (e *NotFoundError) Error() string { return fmt.Sprintf("file %q not found", e.File)}
// 未导出错误类型,不作为公共 API;// 包内仍可用 errors.As。type resolveError struct { Path string}
func (e *resolveError) Error() string { return fmt.Sprintf("resolve %q", e.Path)}错误只处理一次
Section titled “错误只处理一次”调用方收到被调方返回的错误后,可以根据其认知以不同方式处理,包括但不限于:
- 若被调方契约定义了特定错误,用
errors.Is或errors.As匹配并分支处理 - 若错误可恢复,记录日志并优雅降级
- 若错误代表领域特定的失败,返回定义良好的错误
- 将错误返回(包装或原样)
无论采取何种方式,每个错误通常只应被处理一次。不要既记录日志又返回错误,因为上层调用者也可能处理该错误,产生重复噪声。
例如:
| 描述 | 代码 |
|---|---|
|
反例:记录日志并返回 上层很可能会做相同事情,导致日志噪声大,价值低。 |
|
|
正例:包装并返回 上层会处理错误。用 |
|
|
正例:记录并优雅降级 如果该操作非关键,可恢复以提供“降级但不中断”的体验。 |
|
|
正例:匹配特定错误并优雅降级 若被调方契约定义了特定错误且可恢复,则匹配该分支并降级。其它情况包装并返回,由上层处理。 |
|
处理类型断言失败
Section titled “处理类型断言失败”type assertion 的单返回值形式在断言失败时会 panic。务必使用 “comma ok” 惯用法。
| 反例 | 正例 |
|---|---|
|
|
不要 panic
Section titled “不要 panic”生产环境代码必须避免 panic。panic 是级联故障的重要来源。出错时,函数必须返回 error,让调用方决定如何处理。
| 反例 | 正例 |
|---|---|
|
|
Panic/recover 不是错误处理策略。仅当发生不可恢复的问题(如空指针解引用)时才 panic。例外:程序初始化阶段发生严重问题需要中止程序时可以 panic。
var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))即便在测试中,也优先使用 t.Fatal 或 t.FailNow 而非 panic,以确保用例标记为失败。
| 反例 | 正例 |
|---|---|
|
|
使用 go.uber.org/atomic
Section titled “使用 go.uber.org/atomic”sync/atomic 对底层原始类型(int32、int64 等)操作,容易忘记使用原子方法进行读写。
go.uber.org/atomic 通过隐藏底层类型提供类型安全,并提供便捷的 atomic.Bool。
| 反例 | 正例 |
|---|---|
|
|
避免可变全局变量
Section titled “避免可变全局变量”避免修改全局变量,使用依赖注入。函数指针和其他值同样适用。
| 反例 | 正例 |
|---|---|
|
|
|
|
避免在公共结构体中嵌入类型
Section titled “避免在公共结构体中嵌入类型”嵌入会泄露实现细节、限制类型演进、模糊文档。
假设你有一个共享的 AbstractList 实现了多种列表类型,避免在具体列表中嵌入 AbstractList。相反,仅为具体列表手写需要委托给抽象列表的方法。
type AbstractList struct {}
// Add 向列表添加实体。func (l *AbstractList) Add(e Entity) { // ...}
// Remove 从列表移除实体。func (l *AbstractList) Remove(e Entity) { // ...}| 反例 | 正例 |
|---|---|
|
|
Go 支持类型嵌入,是继承与组合的折衷。外层类型隐式获得被嵌入类型的方法,默认委托给内层实例的同名方法。
结构体也会获得一个与类型同名的字段。因此若被嵌入类型是导出的,该字段也是导出的。为了保持向后兼容,外层类型的未来版本必须保留该嵌入。
嵌入很少是必要的,它只是为了避免编写繁琐的委托方法的方便之举。
即便嵌入兼容的抽象列表“接口”而非结构体,虽然给未来带来更多灵活性,但仍然泄露“具体列表使用了抽象实现”的细节。
| 反例 | 正例 |
|---|---|
|
|
无论嵌入结构体还是接口,都会限制类型演进:
- 给嵌入接口新增方法是破坏性变更
- 从嵌入结构体移除方法是破坏性变更
- 移除嵌入类型是破坏性变更
- 替换嵌入类型(即便满足同接口)也是破坏性变更
尽管编写委托方法繁琐,但其代价换来隐藏实现细节、更多变更空间,并消除文档中为发现完整接口而产生的间接性。
避免使用内置标识符
Section titled “避免使用内置标识符”Go 语言规范定义了若干预声明标识符,不应作为程序中的名称。
在不同上下文重用这些标识符要么遮蔽原义,要么让代码困惑。最好情况下编译器会报错;最坏情况下引入潜在的、难以 grep 的 bug。
| 反例 | 正例 |
|---|---|
|
|
|
|
注意编译器不会因使用预声明标识符而报错,但 go vet 等工具应能指出这些以及其它遮蔽情形。
避免 init()
Section titled “避免 init()”能不用 init() 就不用。必须使用时,应尽量:
- 完全确定性,不受运行环境或调用方式影响。
- 避免依赖其它
init()的顺序或副作用。虽然顺序是确定的,但代码会变,init()之间的关系会让代码脆弱且易出错。 - 避免访问/操纵全局或环境状态,如机器信息、环境变量、工作目录、程序参数等。
- 避免 I/O,包括文件系统、网络与系统调用。
无法满足以上要求的代码,应作为辅助在 main()(或生命周期中其它位置)调用,或直接写在 main()。尤其对作为库被使用的包,应保证完全确定性,不做“init 魔法”。
| 反例 | 正例 |
|---|---|
|
|
|
|
考虑到上述,以下情形可能更适合 init():
- 无法用单赋值表达的复杂表达式
- 可插拔钩子,如
database/sql方言、编码类型注册等 - 对 Google Cloud Functions 等的确定性预计算优化
仅在 main 里退出
Section titled “仅在 main 里退出”Go 程序通过 os.Exit 或 log.Fatal* 立刻退出。(不要用 panic 退出,请不要 panic。)
仅在 main() 中调用 os.Exit 或 log.Fatal*。其它函数用返回 error 表达失败。
| 反例 | 正例 |
|---|---|
|
|
原因:多处直接退出有问题:
- 控制流不明显:任意函数都可能退出,难以推理。
- 难以测试:退出会直接结束测试,导致该函数难以测试,且可能跳过未运行的测试。
- 跳过清理:退出会跳过用
defer排队的调用,可能跳过重要清理步骤。
尽可能在 main() 中至多调用一次 os.Exit 或 log.Fatal。若有多处错误需中止程序,将逻辑放在单独函数中并返回 error。
这样可缩短 main(),并将关键业务逻辑置于可测试的独立函数中。
| 反例 | 正例 |
|---|---|
|
|
上例使用了 log.Fatal,同样适用于 os.Exit 或任何会调用 os.Exit 的库。
func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) }}你可以根据需要调整 run() 的签名。例如若需要用特定退出码退出,run() 可以返回退出码而非错误,这样单测也可以直接验证。
func main() { os.Exit(run(args))}
func run() (exitCode int) { // ...}总之,这里的 run() 并非规定:名字、签名与设置都灵活。比如可以:
- 接收未解析的命令行参数(如
run(os.Args[1:])) - 在
main()中解析参数并传给run - 用自定义错误类型承载退出码返回给
main() - 将业务逻辑放在不同层级而非
package main
此指南只要求 main() 中有且仅有一个位置负责真正推出进程。
被序列化的结构体使用字段标签
Section titled “被序列化的结构体使用字段标签”凡是被 JSON、YAML 或其它基于标签的格式序列化的结构体字段,都应标注相应标签。
| 反例 | 正例 |
|---|---|
|
|
原因:序列化结构是一种系统间的契约。对其结构的任何修改——包括字段名——都会破坏契约。通过标签显式制定字段名可防止重构或重命名时意外破坏该契约。
不要“发起即忘”的 goroutine
Section titled “不要“发起即忘”的 goroutine”goroutine 轻量但非免费:至少需要栈内存与调度 CPU。通常开销很小,但若大量创建且不控制生命周期,会带来明显性能问题;还可能导致未使用对象无法 GC、资源无法释放等。
因此生产代码中不要泄露 goroutine。使用 go.uber.org/goleak 在可能启动 goroutine 的包中测试泄漏。
一般而言,每个 goroutine:
- 必须有可预期的终止时机;或
- 必须能被发出“停止”信号
并且应有办法阻塞等待它结束。
例如:
| 反例 | 正例 |
|---|---|
|
|
|
该 goroutine 无法停止,将运行至程序退出。 |
该 goroutine 可通过 |
等待 goroutine 退出
Section titled “等待 goroutine 退出”系统启动的 goroutine 必须可等待退出。常见方式:
-
用
sync.WaitGroup等待多个 goroutine。适用于等待多个。var wg sync.WaitGroupfor i := 0; i < N; i++ {wg.Go(...)}// To wait for all to finish:wg.Wait() -
用一个
chan struct{},让 goroutine 结束时关闭。适用于单个 goroutine。done := make(chan struct{})go func() {defer close(done)// ...}()// 等待其结束:<-done
init() 中不要启 goroutine
Section titled “init() 中不要启 goroutine”init() 中不应启动 goroutine。参见避免 init()。
如果包需要后台 goroutine,必须暴露一个负责管理其生命周期的对象。该对象应提供一个方法(如 Close、Stop、Shutdown),用于发出停止信号并等待其退出。
| 反例 | 正例 |
|---|---|
|
|
|
导入包即无条件启动后台 goroutine。用户无法控制也无法停止。 |
仅在用户请求时才启动 worker,并提供停止方式以释放资源。 若 worker 管理多个 goroutine,应使用 |
性能相关指南仅适用于“热点路径”。
字符串转换优先用 strconv
Section titled “字符串转换优先用 strconv”基础类型与字符串转换,strconv 比 fmt 更快。
| 反例 | 正例 |
|---|---|
|
|
|
|
避免重复的 string-to-byte 转换
Section titled “避免重复的 string-to-byte 转换”不要反复从固定字符串创建字节切片。只转换一次并复用结果。
| 反例 | 正例 |
|---|---|
|
|
|
|
优先指定容器容量
Section titled “优先指定容器容量”尽可能为容器指定容量,以便预先分配,减少后续扩容拷贝。
Map 容量提示
Section titled “Map 容量提示”初始化 map 时尽可能给出容量提示:
make(map[T1]T2, hint)容量提示有助于初始化时更合理地分配桶,减少后续增长的分配次数。注意不同于切片,map 的容量是“提示”,不是严格预分配,达到指定容量前仍可能发生分配。
| 反例 | 正例 |
|---|---|
|
|
|
未提供大小提示;赋值时可能更多分配。 |
提供了大小提示;赋值时可能更少分配。 |
初始化切片(尤其会 append)时尽可能指定容量:
make([]T, length, capacity)与 map 不同,切片容量不是提示:编译器会按指定容量分配,直到长度达到容量前,后续 append() 不会再分配。
| 反例 | 正例 |
|---|---|
|
|
|
|
避免过长的行
Section titled “避免过长的行”避免需要横向滚动或大幅转头才能阅读的行。
建议软上限为 99 字符。尽量在达到该长度前换行,但这不是硬限制,超过也允许。
本文指南有的可客观评估,有的依赖场景与主观判断。
首先要务是:保持一致。
一致的代码更易维护、易推理、降低心智负担,并在新约定出现或修复一类 bug 时更易迁移更新。
相反,在同一代码库中存在多种或冲突风格会增加维护成本、造成不确定与认知失调,进而降低效率、让评审痛苦并诱发缺陷。
应用本指南时,建议以包(或更大)为单位变更;在子包级别应用会在同一代码中引入多种风格,违背上述原则。
分组相似的声明
Section titled “分组相似的声明”Go 支持对相似声明分组。
| 反例 | 正例 |
|---|---|
|
|
这同样适用于常量、变量与类型声明。
| 反例 | 正例 |
|---|---|
|
|
仅分组关联声明。不要将不相关声明分组。
| 反例 | 正例 |
|---|---|
|
|
分组不限于文件顶部,也可用于函数内部。
| 反例 | 正例 |
|---|---|
|
|
例外:变量声明,尤其在函数内部,若彼此相邻应分组,即使不相关也应将同时声明的变量分组。
| 反例 | 正例 |
|---|---|
|
|
import 分组顺序
Section titled “import 分组顺序”应有两组:
- 标准库
- 其它
这也是 goimports 的默认分组方式。
| 反例 | 正例 |
|---|---|
|
|
命名包时:
- 全小写,无大写或下划线
- 在大多数调用点无需通过别名改名
- 简短精炼,记住包名在每个调用点都会完整出现
- 非复数,如
net/url而不是net/urls - 避免 “common”、“util”、“shared”、“lib” 这类信息不足的名字
另见 Package Names 与 Style guideline for Go packages。
遵循社区约定使用大小写混合(MixedCaps)。例外:测试函数可用下划线以归类相关用例,如 TestMyFunction_WhatIsBeingTested。
import 起别名
Section titled “import 起别名”若包名与导入路径最后一个元素不匹配,必须使用别名。
import ( "net/http"
client "example.com/client-go" trace "example.com/trace/v2")其它场景避免起别名,除非存在直接冲突。
| 反例 | 正例 |
|---|---|
|
|
函数分组与排序
Section titled “函数分组与排序”- 函数按大致调用顺序排序
- 文件内函数按接收者分组
因此导出函数应出现在文件前部的 struct、const、var 之后。
newXYZ()/NewXYZ() 可紧随类型定义,先于接收者上的其余方法。
无接收者的工具函数应置于文件末尾。
| 反例 | 正例 |
|---|---|
|
|
尽可能通过先处理错误/特殊情况并早返回或继续循环来减少嵌套,尤其避免多层嵌套。
| 反例 | 正例 |
|---|---|
|
|
不必要的 else
Section titled “不必要的 else”若 if 的两条分支都给变量赋值,可化为单个 if。
| 反例 | 正例 |
|---|---|
|
|
顶层变量声明
Section titled “顶层变量声明”顶层使用标准 var,不要显式类型,除非与表达式类型不同。
| 反例 | 正例 |
|---|---|
|
|
若表达式类型与所需类型不完全一致则需指定类型:
type myError struct{}
func (myError) Error() string { return "error" }
func F() myError { return myError{} }
var _e error = F()// F 返回 myError,但我们需要 error。未导出的全局以 _ 前缀
Section titled “未导出的全局以 _ 前缀”未导出的顶层 var 与 const 使用 _ 前缀,以便在使用处能看出它们是全局符号。
原因:顶层变量与常量具有包作用域。使用通用名易在不同文件中误用错误值。
| 反例 | 正例 |
|---|---|
|
|
例外:未导出的错误值可使用 err 前缀而不加下划线。参见错误命名。
结构体中的嵌入
Section titled “结构体中的嵌入”嵌入类型应位于结构体字段列表顶部,并与普通字段留空行分隔。
| 反例 | 正例 |
|---|---|
|
|
嵌入应带来实质好处,如以合理方式增强功能,且不产生任何用户可见的不良影响(另见:避免在公共结构体中嵌入类型)。
例外:Mutex 不应被嵌入,即便是未导出的类型。参见:互斥锁零值可用。
嵌入不应:
- 纯为美观或图方便
- 让外层类型更难构造或使用
- 影响外层类型零值的可用性
- 作为副作用暴露与外层无关的函数或字段
- 暴露未导出类型
- 改变外层类型的拷贝语义
- 改变外层类型 API 或类型语义
- 嵌入内层类型的非规范形式
- 暴露外层类型的实现细节
- 允许用户观察或控制类型内部
- 以令人意外的方式改变内层函数的通用行为
简单说,谨慎有意识地嵌入。一个试金石是:“这些被嵌入的方法/字段是否都愿意直接加到外层类型?”若答案是“部分”或“否”,不要嵌入,改用字段。
| 反例 | 正例 |
|---|---|
|
|
|
|
|
|
局部变量声明
Section titled “局部变量声明”若明确赋值,使用短变量声明(:=)。
| 反例 | 正例 |
|---|---|
|
|
但在某些情况下,var 更清晰,例如声明空切片。
| 反例 | 正例 |
|---|---|
|
|
nil 是有效的切片
Section titled “nil 是有效的切片”nil 是长度为 0 的有效切片,意味着:
-
不要显式返回长度为 0 的切片。返回
nil。反例 正例 if x == "" {return []int{}}if x == "" {return nil} -
判断切片是否为空,使用
len(s) == 0,不要判断nil。反例 正例 func isEmpty(s []string) bool {return s == nil}func isEmpty(s []string) bool {return len(s) == 0} -
零值(用
var声明的切片)无需make()即可直接使用。反例 正例 nums := []int{}// 或 nums := make([]int)if add1 {nums = append(nums, 1)}if add2 {nums = append(nums, 2)}var nums []intif add1 {nums = append(nums, 1)}if add2 {nums = append(nums, 2)}
记住:虽然 nil 切片有效,但它不同于“已分配、长度为 0 的切片”——一个为 nil,一个不是。在某些场景(如序列化)两者可能被区别对待。
缩小变量作用域
Section titled “缩小变量作用域”尽可能缩小变量与常量的作用域。若与减少嵌套冲突则不要强求。
| 反例 | 正例 |
|---|---|
|
|
若需要在 if 外使用函数结果,就不要强行缩小作用域。
| 反例 | 正例 |
|---|---|
|
|
常量不需要是全局的,除非它们在多个函数/文件中使用,或属于包的外部契约。
| 反例 | 正例 |
|---|---|
|
|
调用中“裸露”的参数会损害可读性。当含义不明显时,用 C 风格注释(/* ... */)标注参数名。
| 反例 | 正例 |
|---|---|
|
|
更好的做法是用自定义类型替代裸 bool,使之更可读、更类型安全,并为未来提供多于两种状态的可能。
type Region int
const ( UnknownRegion Region = iota Local)
type Status int
const ( StatusReady Status = iota + 1 StatusDone // 未来也许会有 StatusInProgress)
func printInfo(name string, region Region, status Status)使用原始字符串避免转义
Section titled “使用原始字符串避免转义”Go 支持原始字符串字面量,可跨多行且包含引号。用它们避免手工转义,提升可读性。
| 反例 | 正例 |
|---|---|
|
|
初始化结构体
Section titled “初始化结构体”使用字段名初始化结构体
Section titled “使用字段名初始化结构体”几乎总是应指定字段名初始化结构体。go vet 现在也强制检查。
| 反例 | 正例 |
|---|---|
|
|
例外:在测试表中,当字段不超过 3 个时可省略字段名。
tests := []struct{ op Operation want string}{ {Add, "add"}, {Subtract, "subtract"},}省略零值字段
Section titled “省略零值字段”使用字段名初始化时,除非提供有意义的上下文,否则省略零值字段,让 Go 自动赋零值。
| 反例 | 正例 |
|---|---|
|
|
这能减少噪声,仅保留有意义值。
当字段名能提供上下文时,即便为零值也应保留,如表驱动测试里的 case:
tests := []struct{ give string want int}{ {give: "0", want: 0}, // ...}零值结构体使用 var
Section titled “零值结构体使用 var”当声明时省略结构体全部字段,用 var 形式:
| 反例 | 正例 |
|---|---|
|
|
这能区分“零值结构体”与“包含非零字段的结构体”,类似于我们声明空切片的偏好,也与Map 初始化的区分一致。
初始化结构体指针
Section titled “初始化结构体指针”初始化结构体指针时使用 &T{} 而不是 new(T),以与结构体初始化风格一致。
| 反例 | 正例 |
|---|---|
|
|
初始化 Map
Section titled “初始化 Map”对空 map 及程序化填充的 map,优先使用 make(..)。这在视觉上将“声明”与“初始化”区分开,并便于之后添加容量提示。
| 反例 | 正例 |
|---|---|
|
|
|
声明与初始化在视觉上相似。 |
声明与初始化在视觉上区分明显。 |
尽可能在 make() 时提供容量提示。见Map 容量提示。
若 map 持有固定元素列表,用字面量初始化更好。
| 反例 | 正例 |
|---|---|
|
|
经验法则:初始化时加固定元素用字面量,否则用 make(若可用则指定容量)。
将格式串定义在 Printf 之外
Section titled “将格式串定义在 Printf 之外”若在字符串字面量之外声明 Printf 风格的格式串,请将其设为 const 值,以帮助 go vet 静态分析。
| 反例 | 正例 |
|---|---|
|
|
命名 Printf 风格函数
Section titled “命名 Printf 风格函数”声明 Printf 风格函数时,确保 go vet 能检测并检查格式串。
尽量使用预定义的 Printf 家族函数名,go vet 默认检查。参见 Printf family。
若不能使用预定义名,请以 f 结尾:如 Wrapf,不是 Wrap。可用 -printfuncs 指定自定义检查的函数名,但它们必须以 f 结尾。
go vet -printfuncs=wrapf,statusf另见 go vet: Printf family check。
对重复核心逻辑的测试,使用带子测试的表驱动测试能减少重复、提升可读性。
当被测系统需要在“多种条件”下测试,且输入/输出的某些部分变化时,应用表驱动测试更佳。
| 反例 | 正例 |
|---|---|
|
|
表驱动测试更易添加上下文、减少重复逻辑,也便于加新用例。
我们约定测试切片命名为 tests,每个用例为 tt。此外,鼓励用 give/want 前缀显式输入/输出。
tests := []struct{ give string wantHost string wantPort string}{ // ...}
for _, tt := range tests { // ...}避免在表测试中引入不必要的复杂性
Section titled “避免在表测试中引入不必要的复杂性”若子测试包含条件断言或其它分支逻辑,表测试会难读难维护。在子测试体(即 for 循环内)需要复杂/条件逻辑时,避免表测试。
大型复杂的表测试会让读者难以调试失败。此类测试应拆为多个测试表或多个独立的 Test... 函数。
一些目标:
- 聚焦尽可能窄的行为单元
- 最小化“测试深度”,避免条件断言
- 确保所有表字段在所有用例中均被使用
- 确保所有测试逻辑在所有用例中运行
此处“测试深度”可理解为“单测中彼此依赖的断言层级(类似圈复杂度)”。更“浅”的测试意味着断言间关系更少且默认非条件化。
具体而言,若使用多条分支路径(如 shouldError、expectCall 等),或大量 if 来设置 mock 预期(如 shouldCallFoo),或在表中放函数(如 setupMocks func(*FooMock)),则表测试会变得混乱。
但当仅基于输入变化而行为变化时,将相似用例放在同一表中有助于对比,而不是拆成多个难以比较的测试。
若测试体简短直观,允许用单个分支(成功/失败)并用 shouldErr 指定错误预期。
| 反例 | 正例 |
|---|---|
|
|
这种复杂性让测试更难改、难理解、难证明正确性。
虽无绝对标准,但在表测试与独立测试间抉择时,应始终优先考虑可读性与可维护性。
并行测试(或在循环体内启动 goroutine/捕获引用等)必须注意在循环体作用域中重绑定循环变量,以确保期望值。
tests := []struct{ give string // ...}{ // ...}
for _, tt := range tests { tt := tt // for t.Parallel t.Run(tt.give, func(t *testing.T) { t.Parallel() // ... })}如上,因为使用了 t.Parallel(),我们必须在循环内声明一个作用域内的 tt 变量。否则测试运行时大多/全部会收到意外的 tt 值,或值在运行中发生变化。
函数式选项模式通过一个不透明的 Option 类型记录内部 options 结构上的信息。构造函数等公共 API 接收可变参数选项,并根据汇总后的信息行动。
对于构造函数及其它可能扩展的公共 API 的“可选参数”,尤其当已有三个或更多参数时,使用此模式。
| 反例 | 正例 |
|---|---|
|
|
|
必须总是提供 cache 与 logger,即使想用默认值: |
仅在需要时提供选项: |
推荐实现方式:定义带未导出 apply(*options) 方法的 Option 接口,并在未导出 options 结构上记录选项。
type options struct { cache bool logger *zap.Logger}
type Option interface { apply(*options)}
type cacheOption bool
func (c cacheOption) apply(opts *options) { opts.cache = bool(c)}
func WithCache(c bool) Option { return cacheOption(c)}
type loggerOption struct { Log *zap.Logger}
func (l loggerOption) apply(opts *options) { opts.logger = l.Log}
func WithLogger(log *zap.Logger) Option { return loggerOption{Log: log}}
// Open 创建连接。func Open( addr string, opts ...Option,) (*Connection, error) { options := options{ cache: defaultCache, logger: zap.NewNop(), }
for _, o := range opts { o.apply(&options) }
// ...}还有用闭包实现该模式的方法,但上述模式为作者提供更大灵活性,对用户也更易调试测试。尤其可以在测试与 mock 中比较选项(闭包无法比较);并可实现其它接口(如 fmt.Stringer)便于输出可读字符串。
另见:
比任何“官方” linter 更重要的是:在整个代码库中一致地进行 lint。
建议至少使用以下 linter,它们能捕获最常见的问题、建立高标准的代码质量,同时不过分武断:
- errcheck 确保错误被处理
- goimports 格式化代码并管理 import
- golint 指出常见风格问题
- govet 分析常见错误
- staticcheck 进行多种静态分析
Lint 运行器
Section titled “Lint 运行器”推荐使用 golangci-lint 作为 Go 代码的 linter 运行器,主要因为它在大代码库中的性能,以及可同时配置/使用多种权威 linter。本仓库有一个示例 .golangci.yml 配置文件,包含推荐的 linter 与设置。
golangci-lint 有众多可用 linter。上述为基础集,鼓励团队按需添加其它适合项目的 linter。