GO开发规范
开发环境
- Visual Code 与微软开发的专用 Go 插件:VS Code + Go
- Jet Brains 出的专用 IDE:Goland
GOPATH
- 项目目录应该位于 $GOPATH/src 下。
- 项目专用的 GOPATH 设置可以使用软链技术来实现:
$ echo $GOPATH
/tmp/gopath
$ pwd
xxx/project
$ ln -s `pwd` $GOPATH
$ tree $GOPATH
/tmp/gopath (${GOPATH})
└── src
└── project -> xxx/project
└── main.go
$ go build project
编码规范
Golang 官方认为统一的编码风格是为了减少开发人员间的时间浪费,但编码风格之间没 有统一定论,存在不少争议,因此 Golang 提倡的是使用特定工具来解决这类问题。
因此 Golang 提供了默认的格式化工具 ,命令:
go fmt [-n] [-x] [packages]
gofmt
具体的标准详见 Effective Go
格式化
- 缩进:使用制表符(tab)
- 行长度:无限制
- 括号:控制结构语法上不需要括号
命名
- 所有命名应该简单清晰:
- 使用 once.Do 而不是 once.DoOrWaitUntilDone 。
包
- 不应该使用下划线和驼峰命名
- 包名和源码目录名应该一致
+因包内导出名称会在包名的命名空间下,导出名称不要以包名作为标识:
名称
- 可导出名称需要大写开头的驼峰命名方式
- 局部名称使用小写开头的驼峰命名方式
- 缩写使用全大写或全小写(变量开头)的命名方式
函数
函数设计应清晰简洁:
- error 作为最后一个返回值
- 代表成功/失败的状态值要作为最后一个返回值
另外为了避免 goroutine 和数据的泄漏,同时为了测试的便利,尽量设计同步方法, 即:
- 内部 goroutine 生存期和函数生存期相同
- 尽量直接返回结果,而不要以其他同步方式
方法
- 接收者不要用 或者 这种笼统没有意义的名称
- 一般使用有代表性的单字母
构造函数
以 New + 类型名称的方式来命名
特别的,如果一个包中只有一个构造函数,可以直接使用 New
接口
- 单函数的接口以函数名 + er 的方式命名
Read
与Reader
Write
与Writer
注释
语法
Golang 注释语法上继承了 C/C++: 、
- 块注释: /* … */
- 行注释: //
Golang 会将特定位置的注释看做文档的一部分,类似于 Python 的 docstring,使用
godoc 可以查询和导出对应的注释文档:
godoc package [name ...]
包注释(package comment )
包注释是在 package 声明语句之前的注释。
/*
Package regexp implements a simple library for regular
expressions.
The syntax of the regular expressions accepted is:
regexp:
concatenation { '|' concatenation }
concatenation:
{ closure }
closure:
term [ '*' | '+' | '?' ]
term: '^'
'$'
'.'
character
'[' [ '^' ] character-ranges ']'
'(' regexp ')'
*/
package regexp
文档注释(doc comment)
在包中所有的顶级声明前的注释将会作为该声明的文档注释,尤其对于可导出的声明,注
释应以该名称开头。
// Compile parses a regular expression and returns, if
successful, a Regexp
// object that can be used to match against text.
func Compile(str string) (regexp *Regexp, err error) {
组合注释
对于有共性的声明,可以使用组合注释,常用在错误声明中。
// Error codes returned by failures to parse an expression.
var (
ErrInternal = errors.New("regexp: internal error")
ErrUnmatchedLpar = errors.New("regexp: unmatched '('")
ErrUnmatchedRpar = errors.New("regexp: unmatched ')'")
...
)
控制结构
分号
Golang 的正式语法使用分号来结束语句,但是通常不需要手动输入,词法分析器会自动 处理。
但需要注意的是,所有控制结构的左大括号必须和关键字( 、 、 与 )位于同一行,另起一行会导致在左大括号前追加一个分号。
同样的,大括号不能省略。
if
- 条件语句不需要加上圆括号
- 省略不必要的 else 语句
- 可以加上合适的初始化语句
result := query()
if err := check(result); err != nil {
return err }
// 不需要 else doSomeThing(result)
for
Golang 只有 for 一种循环结构。
步进
for i := 0; i < 10; i++ {
...
}
Golang 没有逗号表达式,并且++和—操作是语句而非表达式。
##遍历
range
可以遍历数组,切片,字典,管道和字符串。
for key, value := range oldMap {
...
}
遍历字符串
支持 UTF-8 的字符串:
for pos, str := range "SONY大法好" {
fmt.Printf("%q: %d\n", str, pos)
}
‘S’: 0 ‘O’: 1 ‘N’: 2 ‘Y’: 3 ‘大’: 6 ‘法’: 9 ‘好’: 12
switch
相比于 C,Golang 将 switch 改造得更为通用。
- 表达式不限制为常量或整数
- case 可以使用逗号来列举多个条件
- 无需显式 break,但使用 break 可以提前结束
func Factory(name string, value interface{}) interface{} {
var object interface{}
switch name {
case "A", "AA":
object = NewA()
case "B":
object = newB()
if value == nil {
break
}
object.SetValue(value)
}
return object
}
类型选择
对于接口变量,可以使用 switch 来判断其实际类型,这是一个很有用的技巧:
func ErrorWrap(e interface{}) *TraceableError {
var message string
switch e := e.(type) {
case TraceableError:
return &e
case *TraceableError:
return e
case error:
message = e.Error()
default:
message = fmt.Sprintf("%v", e)
}
return ErrorNew(message, 2)
}
退出循环
因为 break 关键字在 switch 块中有特殊含义,因此无法直接用 break 退出循环, 需要借助标签:
package main
import (
"fmt"
)
func main() {
Loop:
for index := 1; index < 10; index++ {
switch index % 5 {
case 1:
break
case 0:
break Loop
default:
fmt.Printf("%v\n", index)
}
}
}
2 3 4
select
select 用法类似于 switch
,专用于轮询多个管道的读取。
包
包需要在头部使用 package 关键字声明包名,一般包名和文件夹名称一致即可。
文件
包内所有文件都在同一个命名空间下,因此建议按照功能组织包内的文件代码,同时在多
人共同开发时分别编辑不同的文件,以减少冲突的可能。
单元测试
功能文件和单元测试文件放在同一个文件夹内,单元测试文件名带上 _test 后缀,其中
包名要声明成 + _test 的形式。
单元测试可以用 . 导入需要测试的包,除此之外都不要使用 . 导入。
Golang 推荐使用表驱动的测试方式:TableDrivenTests
var flagtests = []struct {
in string
out string }{
{"%a", "[%a]"},
{"%-a", "[%-a]"},
{"%+a", "[%+a]"},
{"%#a", "[%#a]"},
{"% a", "[% a]"},
{"%0a", "[%0a]"},
{"%1.2a", "[%1.2a]"},
{"%-1.2a", "[%-1.2a]"},
{"%+1.2a", "[%+1.2a]"},
{"%-+1.2a", "[%+-1.2a]"},
{"%-+1.2abc", "[%+-1.2a]bc"},
{"%-1.2abc", "[%-1.2a]bc"},
}
func TestFlagParser(t *testing.T) {
var flagprinter flagPrinter
for _, tt := range flagtests {
s := Sprintf(tt.in, &flagprinter)
if s != tt.out {
t.Errorf("Sprintf(%q, &flagprinter) => %q, want
%q", tt.in, s, tt.out)
}
}
}
初始化
包内的每个文件都可以声明 init 函数来进行初始化工作,此函数在 import 阶段就会
执行,相当于在 main 函数开始之前执行。
导入
除特殊原因外,不要导入没有使用的模块,会导致编译出错,一般使用 goimports 等 可自动处理。 但因为诸如 init 函数等有副作用的情况存在,只引入而不使用一个包的 情况是存在且合理的,我们要用空白标识符来命名这个引入即可,如 gorm 中指定数据库 驱动:
package main
import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite"
)
错误处理
应该区分错误和异常:
- 错误:作为流程的一部分,被调用方显式返回,调用方主动处理。
- 异常:预料之外出现或者在流程中不应该出现的错误
但是在抽象和封装的时候注意收敛错误类型,避免调用方需要频繁和过多地处理错误。
比如编译一个正则表达式是有可能出错的,但是如果这个正则表达式是由系统内部直接构 造的话,就不应该会出错,因此 regexp 提供了 MustCompile 来收敛编译出错的情 况。
func MustCompile(str string) *Regexp {
regexp, error := Compile(str)
if error != nil {
panic(`regexp: Compile(` + quote(str) + `): ` +
error.Error())
}
return regexp
}
错误
错误是正常流程的一部分,但区别于正确结果的状态,直接 return , 如用户参数校验
失败,数据库查找失败。
- 在 package 中新建一个 errors.go,导出依赖或函数的错误;
- 函数中不再直接 return errors.New(“xxx”) ,因为错误是 Immutable 的,直接
使用全局对象; - 使用 switch…case…default 语句进行类似 Python 的 try…except 方式来处理
错误;
错误是一个接口,不是某种特定类型:
type error interface {
Error() string
}
因此很多系统内会自定义一个带有堆栈信息(通过 runtime)的全局错误类型。 此外,错误内容要使用小写。
异常
异常的出现说明出现了非预期的情况,会导致服务器出现500错误,如数据库连接断掉, 第三方接口调用异常。
Golang 使用 panic 和 recover 来处理异常。
- panic 接收一个错误,中断当前流程
- recover 一般和 defer 配合使用,恢复中断的流程并且处理对应的错误
package main
import (
"fmt"
"regexp"
)
func main() {
regex := `(\d` // should be `(\d)`
defer func() {
e := recover()
if e != nil {
fmt.Printf("compile error: %v\n", regex)
}
}()
regexp.MustCompile(regex)
}
结构和接口
结构
初始化
p1 := new (MyStruct) // type *SyncedBuffer
p2 := &MyStruct{} // type *SyncedBuffer
var s1 MyStruct // type SyncedBuffer
s2 := MyStruct{} // type SyncedBuffer
初始化时可以指定结构成员的初始值:
type MyStruct1 struct {
Value int
}
type MyStruct2 struct {
MyStruct1
ID int }
// 直接初始化
s1 := MyStruct1{
Value: 0, }
// 嵌套结构
s2 := MyStruct2{
ID: 0,
MyStruct1: MyStruct1 {
Value: 1, }
}
方法
获取器(getter)和设置器(setter)
Golang 没有对获取器和设置器提供支持,需要手动实现。
假设有名为 owner 的未导出字段,则其获取器应为大写的方法 Owner ,设置器为
SetOwner ,如:
owner := obj.Owner()
if owner != user {
obj.SetOwner(user)
}
接收者
方法的接收者既可以声明成值类型,也可以声明成指针类型。调用时,Golang 可以自动 进行转换。 但是需要注意的是,声明成值类型时,调用方法时传入的是调用者的拷贝, 而不是调用者本身,因此对接收者的修改将不生效。
接收者类型建议如下:
- map , func , chan :不要使用指针
- 切片类型:如果不存在对切片的重分配,
- 则不要使用指针
- 如果方法会改变接收者,必须使用指针
- 如果接收者结构有类似 sync.Mutex 等用于同步的成员,必须使用
- 一般情况,从实用性的角度出发,建议接收者都声明成指针类型
defer
- 打开文件/连接等后需要 defer 来延时执行关闭
- 慎用 defer 来处理锁
- defer 求值是实时的,因此可以在循环中使用
package main
import "fmt"
func main() {
word := "world"
defer fmt.Printf("%v\n", word)
word = "blueking"
fmt.Printf("hello ")
}
hello world
chan
Golang 的并发模型基于 CSP,并发实体(goroutine)通过管道(channel)进行通信。
管道本质上是一个结构体,维护发送和队列两个队列,创建管道时使用 make :
ch := make(chan int, 0)
不要直接声明,这样可能会导致 goroutine 死锁:
var ch chan int
不带缓冲区的管道专用于同步模式,读写同步。
带缓冲区的管道在以下情况会阻塞:
- 当缓冲区满了之后,发送者会进入发送队列等待
- 当缓冲区为空,接收者会进入接收队列等待
管道可以使用内置函数 close 关闭,关闭后的管道需要注意:
- 重复关闭会导致 panic
- 向关闭的管道发送数据会 panic
- 已关闭的管道读取数据会有以下情况:
- 先返回缓冲区的数据,直到缓冲区为空
- 直接返回类型默认值,第二个返回值是 false
- 关闭管道会退出 for … range 循环
单向管道
管道可以机上只读和只写声明:
- 只读管道: ch <-chan int
- 只写管道: ch chan<- int
这种用法一般用在函数声明中:
func handle(readCh <-chan int, writeCh chan<- int) {
go func() {
v := <-readCh
writeCh <- 2 * v
}()
}
goroutine
goroutine 是 Glolang 提供的一种并发模型,可以通过 关键字来启动轻量级线程来 执行指定的逻辑。 但需要注意的是,goroutine 并不是协程,底层实现是个线程池,一 个 goroutine 在执行的过程中可能会跑在不同的线程和 CPU 上。
线程安全
因为 goroutine 是在线程池中执行,因此我们在 goroutine 中访问闭包需要考虑线程安 全的问题。
Once
sync.Once 提供了一个线程安全的单次执行接口,常用于单例模式或者初始化的场景。
package main
import (
"sync"
)
type singleton struct {}
var instance *singleton
var once sync.Once
func GetInstance() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}
WaitGroup
Golang 没有提供类似 thread.join 等待 goroutine 结束的接口,我们可以用
sync.WaitGroup 来实现:
- 初始化 WaitGroup,加上特定的值
- 激活goroutine,goroutine结束时记得调用 WaitGroup.Done()
- 主流程执行 WaitGroup.Wait()
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
fmt.Printf("hello ")
wg.Done()
}()
wg.Wait()
fmt.Printf("world\n")
}
hello world
Atomic
goroutine 可以使用闭包特性访问外部变量,或者多个 goroutine 共同修改同一个变量, 很容易陷入了变量并发访问的陷阱。 这个时候需要借助 sync.atomic 包提供的一系列 底层内存同步原语来进行同步处理。
相比于公共变量,更推荐使用管道。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var value int64
var wg sync.WaitGroup
wg.Add(2)
fun := func(count int) {
for index := 0; index < count; index++ {
atomic.AddInt64(&value, 1) // not value++
}
wg.Done() }
go fun(100)
go fun(100)
wg.Wait()
fmt.Printf("%v\n", value)
}
Mutex
sync.Mutex 提供了一个基本的互斥锁,而且不同于 Python 的 threading.RLock , 这种锁不可重入。
并且由于以下原因,估计也不会有可重入版本的锁实现出现:
- Golang 故意限制了 goroutine id 的获取
- goroutine 不会固定运行在一个线程中,不能用线程 id 来做局部标识
简单来说,一个锁是没有办法知道自己被哪个 goroutine 获取的,因此这种情况下就要 尤其注意控制锁的粒度了。
- 锁住变量而不是锁住语句块
- 及时上锁及时释放
一个锁不要在粒度不同的逻辑中操作
因为 defer 声明的语句是在函数返回的时候执行的,如果这个时候进行锁的释放,显然 无形中增大了锁的控制粒度(锁住了 Lock 以后的所有语句),因此不建议这样使用。
以下程序会死锁:
package main
import (
"sync"
)
func main() {
var lock sync.Mutex
lock.Lock()
lock.Lock()
}
RWMutex
毫无疑问,锁对程序的性能会造成很大的影响,因此减少锁竞争时间是优化的关键。 在读操作比写操作频繁的情况下,可以用 sync.RWMutex 实现的读优先(读者不竞争)
读写锁来优化性能。
打印进度示例:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var lock sync.RWMutex
var value int64
go func() {
for index := 0; index < 100; index++ {
time.Sleep(200 * time.Millisecond)
lock.Lock()
value++
lock.Unlock()
}
}()
for {
time.Sleep(300 * time.Millisecond)
lock.RLock()
v := value
lock.RUnlock()
fmt.Printf("\r%3d%%", v)
if v == 100 {
break
}
}
}
Context
Context 是官方推荐的并发模式,主要用于调度 goroutine,在很多库和框架都有支持。
因为 goroutine 创建成本极低,一个请求处理的过程中往往会产生很多和这个请求相关 的 goroutine,请求处理结束或者中断后,没能及时结束的 goroutine 会泄漏, goroutine 本质上是线程,会继续占用 CPU,并且容易进一步导致内存泄漏。
Context 是一种接口,相同请求范围内的 goroutine 需要主动检查 Context 的状态来进 行合适的处理:
- Done() <-chan struct{} :返回一个管道,当 Context 取消或者超时的时候会被 关闭
- Err() error :返回 Done 管道关闭的原因
- Deadline() (deadline time.Time, ok bool) :返回将要超时的时间 Value(key interface{}) interface{} :返回 Context 的值
Context 是一个独立的变量,不能保存在结构体中,需要在第一个参数以名称 ctx 主动 传递,如在 gin 中检查 Context 状态以提前结束:
func Messages(ctx *gin.Context) {
messagesCh := make(chan interface{}, 10)
queryMessages(ctx, messagesCh)
ctx.Stream(func(w io.Writer) bool {
select {
case message, ok := <-messagesCh:
if ok {
w.Write(MessageToBytes(message))
}
return ok
case <-ctx.Done():
return false
}
})
}
使用 Context
context.WithCancel 接收一个父 context ,并返回一个新的 context 对象和
cancel 函数。
- 调用 cancel 函数将会关闭该 context ,但不影响父 context
- 父 context 关闭同时也会关闭该 context
切记不要传递 nil 作为 context ,如果不确定使用哪个具体的类型,请传递 context.TODO 。
func main() {
var (
ctx, cancelFunc =
context.WithCancel(context.Background())
wg sync.WaitGroup
ch = make(chan int, 1)
)
wg.Add(1)
go func() {
for {
select {
case value := <-ch:
fmt.Printf("%v\n", value)
case <-ctx.Done():
wg.Done()
return
}
}
}()
for index := 0; index < 5; index++ {
if index == 3 {
cancelFunc()
break
}
ch <- index
}
wg.Wait()
}
随机数
math.rand 生成的是伪随机数,需要初始化随机数种子,且随机性不够,请使用 crypto/rand 。
crypto/rand 和 math/rand 的 Read 函数是兼容的。
项目
- 使用 dep 来进行依赖管理
- vendor 目录要跟随项目进行版本控制,不要每次下载
编译
为了便于维护,编译使用 Makefile 来进行。
编译加速
-i 选项进行加速,相当于:
- go install
- go build
通过 pkg 目录下的 .a 文件保存中间状态。
编译优化
一些有用的 ldflags:
- -w :去掉调试信息
- -X :编译期间覆盖变量,如: -X project/config.Mode=release
在 Paas-Auth 中通过编译覆盖变量来判断当前版本是 debug 版本还是 release 版本,进 行一些差异化的配置:
var Mode = "default"
func init() {
if Mode != "debug" {
gin.SetMode(gin.ReleaseMode)
}
}
通过 build 注入不同的 Mode 来设置 gin 是否开启 release 模式。
条件编译
以 _${GOOS} 命名指定编译的平台,如 xx_linux.go 仅在 linux 中编译。
Go 使用 ${GOOS} 和 ${GOARCH} 两个变量识别当前平台和架构,可以通过修改这两个
变量实现交叉编译。
因此文件名请勿随便使用类似命名,以免触发 Go 默认的条件编译。
在文件顶部加上 build tag,如 gin 中关于 json 处理的文件有如下的 tag:
// +build jsoniter
如果需要启用 jsoniter 来处理 json,指定对应的 build tag 即可:
go build -tags=jsoniter
更小的可执行文件
- -w 选项:去掉调试信息
- -s 选项:去掉符号表
- 使用 upx : upx -9 bin/target
经过验证的第三方依赖
- github.com/gin-gonic/gin:HTTP 框架,有中间件方案;
- github.com/jinzhu/gorm:较好用的 ORM 框架,支持数据库连接池;
- github.com/go-redis/redis:接口类似 redis-py,提供连接池;
- github.com/json-iterator/go :陶文大神的 JSON 库;
- https://github.com/sirupsen/logrus:结构化的日志模块