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)
}()
}
}
参考:
- https://www.jianshu.com/p/ea7dfe61f705
- https://colobu.com/2017/02/07/write-idiomatic-golang-codes
- https://www.cnblogs.com/Survivalist/articles/10596110.html
- https://www.jb51.net/article/151392.html
专题「golang相关」的其它文章 »
- 使用开发框架sponge快速把单体web服务拆分为微服务 (Sep 18, 2023)
- 使用开发框架sponge一天多开发完成一个简单版社区后端服务 (Jul 30, 2023)
- sponge —— 一个强大的go开发框架,以 (Jan 06, 2023)
- go test命令 (Apr 15, 2022)
- go应用程序性能分析 (Mar 29, 2022)
- channel原理和应用 (Mar 22, 2022)
- go runtime (Mar 14, 2022)
- go调试工具 (Mar 13, 2022)
- cobra (Mar 10, 2022)
- grpc使用实践 (Nov 27, 2020)
- 配置文件viper库 (Nov 22, 2020)
- 根据服务名称查看golang程序的profile信息 (Sep 03, 2019)
- goroutine和channel应用——处理队列 (Sep 06, 2018)
- golang中的context包 (Aug 28, 2018)