Golang 开发规范
来源
本规范参考了 uber-go
基本规范
限制单行长度
Go 代码行长度限制为 80 个字符。这有助于在较小的窗口中查看多个文件,以及在较大的窗口中查看多个文件。
相关的声明放在一起,不相关的声明不要放一起
如导入,常量,变量,类型,函数等。
不推荐 | 推荐 |
---|---|
|
|
|
|
EnvVar 不属于 iota 相关
|
|
| 函数内也可分组放在一起
|
|
|
单个变量声明规范
如果单个变量赋值,建议使用 :=
, 但是切片建议使用 var
声明。
不推荐 | 推荐 |
---|---|
|
|
|
|
import 分组规范
import
导入的包应该进行分组,每组之间用空行分隔,每组按照字母顺序排序。
常用分组规则
分两组:
- 标准库
- 第三方库
分三组:
- 标准库
- 第三方库
- 本地库和私有库
不推荐 | 推荐 |
---|---|
|
|
导入别名
如果导入的包名与导入路径中的最后一个单词不一致,应该使用别名。或者导入包名太长时可以使用导入别名。在通常情况下不推荐使用别名,除非包名冲突。
import (
"net/http"
client "example.com/client-go"
trace "example.com/trace/v2"
)
包名
定义包名请遵循以下规范:
- 全部小写, 不要使用大写或下划线或其他特殊符号
- 大多数使用命名导入的情况下,不需要重命名
- 包名尽量简单而有含义,方便记忆和引用
- 不要使用复数,例如
net/url
,而不是net/urls
。 - 不要用“common”,“util”,“shared”或“lib”这些是不好的,信息量不足的名称
函数名
- 函数名使用驼峰命名法,不要使用下划线分隔单词 (部分测试函数可以使用下划线分隔单词)
- 函数名应该尽可能的描述函数的功能,不要使用无意义的名称
函数的排序
函数的排序应遵循以下规范:
- 函数的定义应该尽量按照调用顺序排序
- 同一个文件中函数应放在
struct
,const
,var
定义之后 - 有接收者的函数中的
new
或New
开头的函数应放在前面
不推荐 | 推荐 |
---|---|
|
|
减少嵌套
代码应该有限处理错误或者特殊情况并且尽早返回,而不是嵌套代码块。可以使代码更直观简洁。
不推荐 | 推荐 |
---|---|
|
|
减少不必要的 else
不推荐 | 推荐 |
---|---|
|
|
顶层变量声明
不推荐 | 推荐 |
---|---|
|
|
如果我们希望
|
|
对于未导出的顶层常量和变量使用 _
作为前缀
Bad | Good |
---|---|
|
|
结构体中的嵌入式类型需放在顶部且用空行隔开
不推荐 | 建议 |
---|---|
|
|
嵌入式类型优缺点
优点:
- 代码简洁
- 可以直接访问嵌入类型的方法和字段
- 可以实现接口
缺点:
- 可能会导致外部包访问到未导出的字段和方法
- 会导入嵌入式方法的特殊零值
- 会暴露嵌入式类型的所有字段和方法,所有字段和方法都显示出来,不一定是我们想要的
- 会导致方法调用的不确定性,如果嵌入的类型有相同的方法,会导致调用的不确定性
- 给嵌入式类型的字段赋值不方便,会和其他嵌入式类型混合在一起,不容易区分
不推荐 | 推荐 |
---|---|
|
|
|
|
|
|
nil 是一个有效的 slice
当 slice
为 nil
时表示一个长度为 0 的切片
- 当返回切片为空时不应该返回一个空切片而是返回
nil
不推荐 | 建议 |
---|---|
|
|
- 使用
len(s) == 0
判断是否为空而不是s != nil
不推荐 | 建议 |
---|---|
|
|
- 零值切片(用
var
声明的切片)可立即使用,无需调用make()
创建
不推荐 | 推荐 |
---|---|
|
|
减少变量作用域
尽量减少变量作用域,除非变量在其他地方也被调用
Bad | Good |
---|---|
|
|
| 由于 err 变量在 if 语句中声明,所以它的作用域仅限于 if 语句块。这样可以避免在 else 语句中使用相同的变量名。
|
使用原生字符串而不是转义
当字符串内有转义字符时,优先使用 ` 来包裹字符串,表示这是原生字符串,不需要转义
不推荐 | 推荐 |
---|---|
|
|
初始化结构体
使用字段名初始化结构
初始化结构体赋值的时候尽量添加字段名,这样可以避免因为结构体字段的变化导致初始化错误
Bad | Good |
---|---|
|
|
忽略 0 值的字段
若初始化的字段为默认的 0 值,可以省略字段名
不推荐 | 推荐 |
---|---|
|
|
若初始化全为 0 值的结构变量,使用 var
不推荐 | 推荐 |
---|---|
|
|
初始化结构体指针
初始化结构体指针时,使用 &
符号, 而不是 new()
不推荐 | 推荐 |
---|---|
|
|
使用 make()
初始化 map
如果初始化 map
, 如果 map
有初始值,建议使用 :=
而不是 make()
,如果没有初始值,使用 make()
,且建议估计 map
大小,设置初始化容量
不推荐 | 推荐 |
---|---|
|
|
|
|
若在 Printf 外定义 format 字符串,建议使用 const
如果在 Printf
外定义格式化字符串,建议使用 const
常量,这样可以避免重复定义格式化字符串, 且可以帮助 go vet
进行静态分析。
Bad | Good |
---|---|
|
|
开发原则
error 处理
错误类型
错误类型通常有两种:
- 静态类型: 由
errors.New()
创建的错误类型,通常用于预定义的错误, 错误信息不变 - 动态类型: 由
fmt.Errorf()
创建的或者自定义的错误类型,通常用于动态错误信息
错误匹配
当判断错误类型时,不要使用 ==
进行比较,应该使用 errors.Is()
或者 errors.As()
进行比较,且应该创建顶级错误变量用于判断
// package foo
var ErrCouldNotOpen = errors.New("could not open")
func Open() error {
return ErrCouldNotOpen
}
// package bar
if err := foo.Open(); err != nil {
if errors.Is(err, foo.ErrCouldNotOpen) {
// handle the error
} else {
panic("unknown error")
}
}
错误包装
我们可以使用 fmt.Errorf()
或者 errors.Wrap()
来包装错误,这样可以保留原始错误信息,同时添加额外的上下文信息
注意
fmt.Errorf()
在 Go 1.13 之后,可以使用 %w
格式化符号来包装错误,这样可以使用 errors.Is()
和 errors.As()
来判断错误类型, 如果使用 %v
格式化符号,会丢失错误类型信息,导致无法使用 errors.Is()
和 errors.As()
来判断错误类型。
s, err := store.New()
if err != nil {
return fmt.Errorf(
"new store: %w", err)
}
错误命名
普通错误变量的命名应该以 Err
开头,后面跟错误的描述,使用驼峰命名法
var (
// 导出以下两个错误,以便此包的用户可以将它们与 errors.Is 进行匹配。
ErrBrokenLink = errors.New("link is broken")
ErrCouldNotOpen = errors.New("could not open")
// 这个错误没有被导出,因为我们不想让它成为我们公共 API 的一部分。 我们可能仍然在带有错误的包内使用它。
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)
}
依次处理错误
在处理错误时, 我们应该使用 errors.Is()
和 errors.As()
来判断错误类型,根据不同的错误类型进行不同的处理,如果错误无法处理,则应该明确返回错误可以来源,方便上级处理。
描述 | 代码 |
---|---|
不推荐: 记录错误并将其返回 堆栈中的调用程序可能会对该错误采取类似的操作。这样做会在应用程序日志中造成大量噪音,但收效甚微。 |
|
推荐: 将错误换行并返回 堆栈中更靠上的调用程序将处理该错误。使用 |
|
推荐: 记录错误并正常降级 如果操作不是绝对必要的,我们可以通过从中恢复来提供降级但不间断的体验。 |
|
推荐: 匹配错误并适当降级 如果被调用者在其约定中定义了一个特定的错误,并且失败是可恢复的,则匹配该错误案例并正常降级。对于所有其他案例,请包装错误并返回。 堆栈中更靠上的调用程序将处理其他错误。 |
|
类型断言
在我们需要类型断言的时候,务必使用 ok
返回值判断,否则会导致 panic
不推荐 | 推荐 |
---|---|
|
|
尽量不使用 panic
在生产环境中尽量不要使用 panic
, panic
是 级联失败 的主要原因,如果必须使用 panic
,务必使用 recover()
进行处理。
使用 atomic 原子操作
在并发编程中,我们应该使用 atomic
包提供的原子操作来保证并发安全,可以保证基本类型如 int32, int64
在一个时刻只有一个 goroutine 可以访问。
对于其他类型,则推荐使用 channel
或 sync lock
进行控制
不推荐 | 推荐 |
---|---|
|
|
不要在公共的结构中使用嵌入类型
不要在公共的结构中使用嵌入类型, 主要原因是如果嵌入多个类型,则会导致大量公开接口和变量混杂,不好管理和配置,且相同的变量和函数可能冲突。且无法保证后续的版本不会冲突。
不推荐 | 推荐 |
---|---|
|
|
避免使用内置名称
声明变量的时候应避免使用内置的名称,如 len
, cap
, append
, copy
, new
, make
, close
, delete
, complex
, real
, imag
, panic
, recover
, print
, println
, error
, string
, int
, uint
, uintptr
, byte
, rune
, float32
, float64
, bool
, true
, false
, iota
, nil
, true
, false
, iota
, nil
, append
, cap
, close
, complex
, copy
, delete
, imag
, len
, make
, new
, panic
, print
, println
, real
, recover
, string
, uint
, uintptr
, byte
, rune
, float32
, float64
, int
, int8
, int16
, int32
, int64
, uint
, uint8
, uint16
, uint32
, uint64
, uintptr
, bool
等
避免使用 init
函数
init
函数是在包被导入的时候自动执行的,但是由于 init
函数的执行顺序是不确定的,所以在 init
函数中初始化的变量可能会导致不确定的结果,所以尽量避免使用 init
函数。
什么时候要用 init()
- 当导入包需要赋值的过程十分复杂,且表达式不能用单个变量赋值时
- 使用可插入的钩子函数如
database/sql
时 - 对某些预计算方法进行初始化优化
提前配置 slice 的容量
若能提前知道大概得数据量,应提前给 slice
配置容量,减少 slice
扩容的次数,提高性能。
不推荐 | 推荐 |
---|---|
|
|
|
|
使用 Exit 或 Fatal 退出主程序
在主程序中,如果遇到错误,应该使用 os.Exit
或 log.Fatal
退出程序,而不是使用 panic
,因为 panic
会导致程序崩溃,而 os.Exit
或 log.Fatal
会正常退出程序。且应该将错误传递到最终的调用者,而不是在每个函数中处理致命错误。
相关信息
最好仅在main()
中调用其中一个 os.Exit
或者 log.Fatal*
。所有其他函数应将错误返回到 main
主程序中。
原因:
- 太多函数可以调用
Fatal
的话会导致难以控制程序流 Fatal
错误可能导致test
无法全部执行Fatal
错误可能导致defer
无法执行
Bad | Good |
---|---|
|
|
在序列化的结构体中需声明 Tag
在序列化的结构体中,应该声明 json
或 xml
等序列化的 tag
,以便序列化和反序列化时能够正确的解析数据。
推荐 | 不推荐 |
---|---|
|
|
注意协程 goroutine 的使用
注意
在使用协程时,应该注意以下几点:
- goroutine 的数量应该受限制,避免无限制的创建 goroutine
- goroutine 必须有一个可预测的停止时间
- goroutine 必须有停止的方法
Bad | Good |
---|---|
|
|
没有办法阻止这个 goroutine。这将一直运行到应用程序退出。 | 这个 goroutine 可以用 |
等待 goroutines 退出
在 goroutine 执行时,需要使用函数保证主程序不退出,否则会终止 goroutine 的执行。
有两种常用的方法可以做到这一点:
使用
sync.WaitGroup
.
如果您要等待多个 goroutine,请执行此操作var wg sync.WaitGroup for i := 0; i < N; i++ { wg.Add(1) go func() { defer wg.Done() // ... }() } // To wait for all to finish: wg.Wait()
添加另一个
chan struct{}
,goroutine 完成后会关闭它。
如果只有一个 goroutine,请执行此操作。done := make(chan struct{}) go func() { defer close(done) // ... }() // To wait for the goroutine to finish: <-done
不要在 init()
中使用 goroutine
在 init()
函数中使用 goroutine
会导致程序的初始化变得复杂,因为 init()
函数是在程序启动时执行的,而 goroutine
是异步执行的,可能会导致程序的初始化顺序变得不确定。
性能优化
优先使用 strconv 而不是 fmt
在进行字符串转换时,尽量使用 strconv
包,而不是 fmt
包,因为 fmt
包是比较重的,而 strconv
包是比较轻的。strconv
包转换速度更快,所需资源更少。
指定 map, slice 的容量
如果提前知道大概容量,请提前赋值,以避免不必要的内存分配和自动扩容。