go语言开发规范

1 代码格式化

go默认已经有了gofmt工具,但是建议使用goimport工具,这个在gofmt的基础上增加了自动删除和引入包,目前IDE基本都支持goimports,安装goimports:

go get golang.org/x/tools/cmd/goimports

对import的包进行分组管理,用换行符分割,而且标准库作为分组的第一组。如果你的包引入了三种类型的包,有标准库包、程序内部包、第三方包,建议采用如下方式进行组织你的包:

import (
    "fmt"
    "os"

    "kmg/a"
    "kmg/b"

    "code.google.com/a"
    "github.com/b"
)

在项目中不要使用相对路径引入包:

// 错误示例
import "../net"

// 正确的做法
import "github.com/repo/proj/src/net"



2 注释

代码注释有两种方式:

  • 行注释://
  • 块注释:/* …… */

如果想在每个文件中的头部加上版权注释,需要在版权注释和package注释前面加一个空行,否则版权注释会作为package的注释,如下所示:

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

/*
Package net provides a portable interface for network I/O, including
TCP/IP, UDP, domain name resolution, and Unix domain sockets.
......
*/
package net

注:使用//注释时,在//之后应该加一个空格。


package里导出的变量和函数前面必须写注释,如果不添加注释,golint检查无法通过,如下所示:

const (
    // StatusRunning 运行状态
    StatusRunning = 1
)

// Send 发送消息
func Send(content []byte){
    ...
}



3 一些大小的约定

  • 单个文件代码行数建议不超过500行。
  • 单个函数长度不超过100行。
  • 函数两个要求:单一职责、要短小。
  • 单行语句不能过长,如不能拆分需要分行写,一行最多120个字符。
  • 函数中缩进嵌套必须小于等于3层,禁止出现以下这种锯齿形的函数,应通过尽早通过return等方法重构。

错误的示范:

if ... {
    if ... {
        if ... {
            
        } else {
            if ... {
                
            }
        }
    } else {
        
    }
}
  • 保持函数内部实现的组织粒度是相近的。

建议把下面代码整理下:

func main() {
    initLog()
    
    //这一段代码的组织粒度,明显与其他的不均衡
    orm.DefaultTimeLoc = time.UTC
    sqlDriver := beego.AppConfig.String("sqldriver")
    dataSource := beego.AppConfig.String("datasource")
    modelregister.InitDataBase(sqlDriver, dataSource)
    
    Run()
}

改为:

 func main() {
    initLog()

    initORM()  //修改后,函数的组织粒度保持一致 

    Run()
}



4 命名规则

(1) 局部变量命名规则

局部变量名称一般遵循驼峰法,但遇到特有名词时,需要遵循以下规则:

  • 如果变量为私有,且特有名词为首个单词,则使用小写,如apiClient。
  • 其它情况都应当使用该名词原有的写法,如 APIClient、repoID、UserID。
  • 错误示例:UrlArray,应该写成urlArray或者URLArray。

如果变量类型为bool类型,则名称应以Is、Has、Can或Allow开头,例如:isExist、hasConflict、canManage、allowGitHook。

在相对简单的环境(对象数量少、针对性强)中,可以将一些名称由完整单词简写为单个字母,例如user简写为u。


(2) 全局变量命名规则

全局变量名称一般遵循驼峰法,不要使用简写,做到见其名知其义。


(3) 包的命名规则

包的命名使用小写,尽量不要使用下划线或者混合大小写,包名应该用单数的形式,比如util、model,而不是utils、models。


(4) 函数命名规则

使用驼峰式命名,名字可以长但是得把功能描述清楚,函数名应当是动词或动词短语,如postPayment、deletePage、save等,也可以在名词前面加上get、set、is前缀。


(5) 结构体命名规则

结构体名应该是名词或名词短语,如Custome、WikiPage、Account、AddressParser,避免使用Manager、Processor、Data、Info这样的名称,结构体名称不应该是动词。

带mutex的struct的接收者receivers必须是带指针的接收者,具体示例:

type foo struct {
    mutex sync.Mutex
    ...
}

// 这里的接收者必须是指针,保证只对同一个锁操作,达到对同一个资源操作的互斥效果。
func (f *foo) Write (content []byte) error {
    f.mutex.Lock()
    defer f.mutex.Unlock()
    
    ...
}


(6) 接口命名规则

单个函数的接口名以er作为后缀,如Reader、Writer。接口的实现则去掉er。

type Reader interface {
    Read(p []byte) (n int, err error)
}

两个函数的接口名综合两个函数名,后面加er:

type WriteFlusher interface {
    Write([]byte) (int, error)
    Flush() error
}

三个以上函数的接口名,抽象这个接口的功能,类似于结构体名:

type Car interface {
    Start([]byte)
    Stop() error
    Recover()
}


(7) 函数接收者命名规则

Receiver的名称应该缩写,一般使用一个或者两个字符作为Receiver的名称,如下所示:

func (f foo) method1() {
    ...
}

func (f *foo) method2() {
    ...
}


(8) 常量命名

使用驼峰式命名,如果是枚举类型的常量,需要先创建相应类型,例如:

type Scheme string

const (
    HTTP  Scheme = "http"
    HTTPS Scheme = "https"
)

常量名称容易混淆的情况下,为了更好地区分枚举类型,可以使用完整的前缀:

type Status string

const (
    StatusRunning Status = 1
    StatusStop Status = 2
)


不要写类似于下面这种代码,如果没有注释,不知到1代表什么意义。

if status == 1 {
    ...
}

应该先定义常量,多个同类型的常量方便统一维护,应该改为:

const (
    // 运行状态
    StatusRunning = 1
)

if status == StatusRunning {
    ...
}


(9) 单元测试文件和函数命名

  • 单元测试文件名命名必须在文件名后面加上_test,表示该文件为单元测试文件,例如example_test.go。
  • 测试用例的函数名称必须以Test开头,例如TestExample。
  • 如果测试的函数是某个对象的方法,命名方式为Test+对象名_方法名,例如TestAccount_Insert。



5 error处理

为了编写强壮的代码,不要忽略错误,也不要使用panic抛出异常,而是要处理每一个错误,尽管代码写起来可能有些繁琐。

error处理不要写成下面这种形式,一旦有错误发生,尽可能return返回。

if err != nil {
    // error handling
} else {
    // normal code
}

而是写成下面这种形式:

if err != nil {
    // 如果是最顶层函数,处理错误。
    // 如果不是最顶层函数,可以在原来错误基础上添加新的错误说明,然后返回。
    return
}

// normal code

error的错误描述如果是英文必须为小写,不需要标点结尾。


go语言自带的包没有打印堆栈信息,多级错误返回情况下,比较难以判断返回错误的根因是在哪一个环节产生的,使用 github.com/pkg/errors 可以包装错误信息,如下所示:

package main

import (
    "fmt"
    "io/ioutil"

    "github.com/pkg/errors"
)

func main() {
    err := test1("hello.txt")
    if err != nil {
        // 无打印堆栈信息
        fmt.Println("1", err)

        // 只获取根因(无包装信息)
        fmt.Println("2", errors.Cause(err))

        // 使用%+v打印堆栈信息
        fmt.Printf("3 %+v\n", err)
        return
    }
}

func test1(file string) error {
    if err := test2(file); err != nil {
        // 因为下层错误已经包装过数据,无需重复包装
        return err
    }
    return nil
}

func test2(file string) error {
    content, err := ioutil.ReadFile(file)
    if err != nil {
        // 把错误的根因和消息包装后返回。
        return errors.Wrap(err, "read file error")
    }

    fmt.Println(string(content))

    return nil
}



6 string和slice

(1) 判断字符串为空

不要使用:

if len(str) == 0 {
    ...
}

而是使用:

if str == "" {
    ...
}


(2) 判断slice为非空

不要使用:

if slice != nil && len(slice) > 0 {
    ...
}

而是使用:

if len(slice) > 0 {
    ...
}


(3) byte/string slice的相等性比较

不要使用:

bytes.Compare(s1, s2) == 0
bytes.Compare(s1, s2) != 0

而是使用:

bytes.Equal(s1, s2) == 0
bytes.Equal(s1, s2) != 0


(4) 检测是否包含字串

不要使用 strings.IndexRune(s1, ‘x’) > -1及其类似的方法IndexAny、Index检查字符串包含, 而是使用strings.ContainsRune、strings.ContainsAny、strings.Contains来检查。


(5) 复制slice

不要使用遍历方式:

var b1, b2 []byte
for i, v := range b1 { 
    b2[i] = v
}
或
for i := range b1 { 
    b2[i] = b1[i]
}

而是使用:

copy(b2, b1)


(6) 把一个slice追加到另一个slice后面

不要使用遍历方式:

var a, b []int
for _, v := range a {
    b = append(b, v)
}

而是使用:

var a, b []int
b = append(b, a...)



7 布尔值判断

判断真假不要使用:

if b == true {
    ...
}
if b == false {
    ...
}

而是使用:

if b {
    ...
}
if !b {
    ...
}



8 参数传递

  • 参数比较多时(7个以上),建议把参数放到结构体里,通过结构体传参。
  • 对于大量数据的struct使用指针传参。
  • 对于map、slice、chan这些参数不需要传递指针,因为map、slice、chan是引用类型。


9 闭包使用

在循环或者goroutine中使用闭包,必须使用显式的变量调用。

典型的闭包错误使用方式:

func main() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func() {
            fmt.Println(i)
            wg.Done()
        }()
    }
    wg.Wait()
}

// 执行结果是55555,这显然不是我们想要的结果(01234)

正确的使用方式:

func main() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        // 显式传参进去
        go func(j int) {
            fmt.Println(j)
            wg.Done()
        }(i)
    }
    wg.Wait()
}



10 单元测试

(1) 无依赖的功能测试

单元测试的原则,就是测试的函数方法,不要受到所依赖环境的影响,比如网络访问等。

以下面一个简单的计算器代码为例:

package calculator

import (
    "fmt"
    "strconv"
)

const (
    ErrorDivision = "error: the dividend cannot be 0."
    ErrorOp       = "errorr: unknown op type, only support operations +-*/"
)

type Calculator struct {
    x1 int
    x2 int
    op string
}

func (c *Calculator) String() string {
    return fmt.Sprintf("%d%s%d=", c.x1, c.op, c.x2)
}

// Run 加减乘除计算
func (c *Calculator) Run() string {
    switch c.op {
    case "+":
        return strconv.Itoa(c.x1 + c.x2)
    case "-":
        return strconv.Itoa(c.x1 - c.x2)
    case "*":
        return strconv.Itoa(c.x1 * c.x2)
    case "/":
        if c.x2 == 0 {
            return ErrorDivision
        }
        return strconv.Itoa(c.x1 / c.x2)
    default:
        return ErrorOp
    }
}


测试代码:

package calculator

import (
    "reflect"
    "testing"

    "github.com/google/go-cmp/cmp"
)

// 测试示例1,有可能会这样写测试,逐个实例化后判断
func TestCalculator_Run1(t *testing.T) {
    got := (&Calculator{10, 2, "+"}).Run()
    expected := "12"
    if got != expected {
        t.Errorf("got: %v, expected: %v", got, expected)
    }

    got = (&Calculator{10, 2, "-"}).Run()
    expected = "8"
    if got != expected {
        t.Errorf("got: %v, expected: %v", got, expected)
    }

    got = (&Calculator{10, 2, "*"}).Run()
    expected = "20"
    if got != expected {
        t.Errorf("got: %v, expected: %v", got, expected)
    }

    got = (&Calculator{10, 2, "/"}).Run()
    expected = "5"
    if got != expected {
        t.Errorf("got: %v, expected: %v", got, expected)
    }
}

// 测试示例2,第1种测试太啰嗦了,可以这样优化,看起来更简洁
func TestCalculator_Run2(t *testing.T) {
    // 列举测试数据
    tests := []struct {
        input    *Calculator
        expected string
    }{
        {&Calculator{10, 2, "+"}, "12"},
        {&Calculator{10, 2, "-"}, "8"},
        {&Calculator{10, 2, "*"}, "20"},
        {&Calculator{10, 2, "/"}, "5"},
        {&Calculator{10, 0, "/"}, ErrorDivision},
        {&Calculator{10, 2, "$"}, ErrorOp},
    }

    // 判断
    for _, v := range tests {
        got := v.input.Run()
        expected := v.expected
        if !reflect.DeepEqual(got, expected) {
            t.Errorf("got: %v, expected: %v", got, expected)
        }
    }
}

// 测试示例3,第2种测试写法虽然很简洁,但是当某个输入判断不通过时,而且测试数据多的时候,不好区分是哪个输入测试失败,可以再优化一下
func TestCalculator_Run3(t *testing.T) {
    // 列举测试数据
    tests := map[string]struct {
        input    *Calculator
        expected string
    }{
        "加法":    {&Calculator{10, 2, "+"}, "12"},
        "减法":    {&Calculator{10, 2, "-"}, "8"},
        "乘法":    {&Calculator{10, 2, "*"}, "20"},
        "除法":    {&Calculator{10, 2, "/"}, "5"},
        "被除数为0": {&Calculator{10, 0, "/"}, ErrorDivision},
        "非法操作符": {&Calculator{10, 2, "$"}, ErrorOp},
    }

    // 判断
    for key, v := range tests {
        got := v.input.Run()
        expected := v.expected
        if !reflect.DeepEqual(got, expected) {
            t.Errorf("%s: got: %v, expected: %v", key, got, expected)
        }
    }
}

// 测试示例4,第3种测试方法可以很快定位哪个输入测试失败,如果比较对象的元素很多的时候,虽然最后可以判断出结果不一样,
// 但是不一样在哪里,没有指出来,所有引入一个强大的比较对象的包go-cmp,类似git diff比较不同。
func TestCalculator_Run4(t *testing.T) {
    // 列举测试数据
    tests := map[string]struct {
        input    *Calculator
        expected string
    }{
        "加法":    {&Calculator{10, 2, "+"}, "12"},
        "减法":    {&Calculator{10, 2, "-"}, "8"},
        "乘法":    {&Calculator{10, 2, "*"}, "20"},
        "除法":    {&Calculator{10, 2, "/"}, "5"},
        "被除数为0": {&Calculator{10, 0, "/"}, ErrorDivision},
        "非法操作符": {&Calculator{10, 2, "$"}, ErrorOp},
    }

    // 判断
    for key, v := range tests {
        got := v.input.Run()
        expected := v.expected
        if result := cmp.Diff(got, expected); result != "" {
            t.Error(key, result)
        }
    }
}


(2) mock单元测试

在开发过程中往往需要配合单元测试,但是很多时候,单元测试需要依赖一些比较复杂的准备工作,比如需要依赖数据库环境,需要依赖网络环境,单元测试就变成了一件非常麻烦的事情。

mock对象就是为了解决依赖环境的问题,mock(模拟)对象能够模拟实际依赖对象的功能,同时又不需要非常复杂的准备工作,你需要做的,仅仅就是定义对象接口,然后实现它,再交给测试对象去使用。

安装go mock工具:

go get github.com/golang/mock/gomock
go get github.com/golang/mock/mockgen

在$GOPATH/src目录下新建一个项目hello,新建一个hello.go文件,内容如下:

package hello

type Talker interface {
    SayHello(word string) (response string)
}


新建persion.go文件,在文件里定义一个Persion结构体,并实现Talker接口,persion.go文件内容如下:

package hello

import "fmt"

type Person struct {
    name string
}

func NewPerson(name string) *Person {
    return &Person{
        name: name,
    }
}

func (p *Person) SayHello(name string) (word string) {
    return fmt.Sprintf("hello %s, welcome come to our shop, my name is %s", name, p.name)
}

假设商店有一个迎宾员,实现了Talker接口,迎宾员能够自动向客人说SayHello,新建shop.go文件内容如下:

package hello

type Shop struct {
    Usher Talker
}

func NewShop(t Talker) *Shop {
    return &Shop{
        Usher: t,
    }
}

func (c *Shop) Meeting(guestName string) string {
    return c.Usher.SayHello(guestName)
}

使用mockgen工具模拟Shop对象:

# 新建文件夹
mkdir mock_hello

# mock对象
mockgen -source=hello.go > mock_hello/mock_hello.go

使用这个mock对象,新建一个测试文件shop_test.go文件:

package hello

import (
    "testing"
    "hello/mock_hello"

    "github.com/golang/mock/gomock"
)

func TestShop_Meeting(t *testing.T) {
    ctl := gomock.NewController(t)
    mock_talker := mock_hello.NewMockTalker(ctl)
    mock_talker.EXPECT().SayHello(gomock.Eq("张三")).Return("你好张三,欢迎光临。")

    shop := NewShop(mock_talker)
    t.Log(shop.Meeting("张三"))
    //t.Log(shop.Meeting("李四"))
}

mock对象的SayHello可以接受的参数有gomock.Eq(x interface{})和gomock.Any(),前一个要求测试参数必须相等,第二个允许传入任意参数。



11 README文件

每个文件夹下都应该有一个README文件,该文件是对当前目录下所有文件的一个概述和主要方法描述,并给出一些相应的链接地址,包含代码所在地、引用文档所在地、API文档所在地。

README文件不仅是对自己代码的一个梳理,更是让别人在接手你的代码时能帮助快速上手的有效资料。所以每一个写好README文档的程序员绝对都是一个负责任的好程序员。



12 合理规划项目的目录

合理规划目录,一个目录中只包含一个包(实现一个模块的功能),如果模块功能复杂考虑拆分子模块,或者拆分目录。

不要把不同功能模块放到一个包下:

project
├─  config.go
├─  controller.go
├─  filter.go
├─  flash.go
└─  log.go

而是把各个模块功能分到不同目录:

project  
├─cache  
│  │  cache.go  
│  │  conv.go  
│  │        
│  └─redis  
│          redis.go  
├─config  
│  │  config.go  
│  │  fake.go  
│  │  ini.go  
│  └─yaml  
│     yaml.go  
└─log  
      conn.go  
      console.go  
      log.go  



13 channel和goroutine

(1) channel

在任何情况下,不要在读取channel数据端关闭channel,因为发送端在不知情况下继续发送数据到该channel时会造成panic。要停止使用channel正确做法是在channel发送端关闭,接收端可以检测到channel是否已关闭。

关于使用写入channel超时处理,有可能会下面这样写:

ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()

for{
    ...
    
    select {
    case dataChannel <- msg:
    case <-ticker.C:
        // 超时处理
    }
}

上面这样会有个小问题,当写入的channel和超时的channel同时触发的时候(当然这个情况概率是比较小的),select会随机选择执行一个分支,如果select选择了触发超时分支,如果处理不当会造成该数据缺失了,为了避免这个问题,做一些修改,如下面代码所示:

ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()

for{
    ...
    
    select {
    case dataChannel <- msg:
    default: // forbid block
    }
    
    select {
    case <-ticker.C:
        // 超时处理
    default: // forbid block
    }
}


(2) goroutine

如果启动的goroutine是用来做任务的,建议要写成可手动结束的goroutine,防止goroutine泄漏:

func worker(ctx context.Context, jobChan <-chan Job) {
    select {
    case job <- jobChan:
        Process(job)
    case <-ctx.Done():
        // 结束worker
        return
    }
}

如果不受限制的启动新goroutine,有可能会消耗完系统资源,建议使用goroutine池,使用有限的goroutine去做共同任务:

func workPool(ctx context.Context, jobChan chan Job) {
    for i := 0; i < 10000; i++ {
        go func() {
            worker(ctx, jobChan)
        }()
    }
}



参考:



专题「golang相关」的其它文章 »