当前位置: 首页 > Go语言, 代码艺术, 工程能力 > 正文

Go语言开发规范

相信很多开发者在Go有了一定的熟悉之后,就在思考如何写出更佳go style的代码。在网上搜索了之后,想必肯定找出了两份经典的文档说明:

Go Code Review Comments:https://github.com/golang/go/wiki/CodeReviewComments

Uber Go Style Guide:https://github.com/uber-go/guide/blob/master/style.md (译文版

没错,这确实两份相当经典的Go语言开发规范的指导性文档。Go Code Review Comments是golang官方给出的指导性规范,结合官方出品的《Effective Go》入门教程,可以帮助我们编写优雅,高效的 Go 代码。笔者在此也是深究上述资料,并结合自己在Go开发以及Code Review方面的经验,在此总结出了自己在Go编程规范方面的思考和一些工程经验方面的经验性总结,和大家分享。

1. 项目目录规范

my-project (此处以微服务项目为例)
├── CHANGELOG.md
├── README.md
├── build  // 构建脚本
│   ├── Makefile
│   ├── README.md
│   ├── compile.sh // 编程二进制
│   ├── deploy.sh // 构建docker/push
│   └── dockerfiles
│       ├── apiserver/
│       ├── envoy/
│       ├── task_server/
│       └── task_worker/
├── deploy // 部署服务相关(配置、资源)
│   ├── README.md
│   ├── conf/
│   └── resource/
├── go.mod
├── go.sum
├── jenkins.groovy // ci自动化脚本 
└── pkg // 项目核心代码目录
    ├── apiserver // 业务子模块
    │   ├── api/v1/  // 对api提供版本控制
    │   ├── cmd/  // 自模块入口函数(含.yaml配置文件)
    │   ├── common/  // 模块内的全局常量/func定义
    │   ├── config/  // 配置解析、初始化
    │   ├── server/  // 业务核心层(Control层)
    │   ├── service/ // 接口服务层(validate/render/service层调用/外部调用)
    │   ├── test/ // 模块测试(接口层测试)
    │   └── util/ // 模块内的实用工具
    ├── common/ // 项目内的全局常量/func定义
    ├── infra/ // 通用类库封装
    ├── model // model层
    │   ├── db_project.sql
    │   └── table.go
    └── task_center // 通用任务框架(支持grpc/http/cron接收并投递任务,基于machinery框架)
        ├── README.md
        ├── api/
        ├── cmd/
        ├── common/
        ├── config/
        ├── server/ // task接收并投递(Broker)
        ├── tasks/ // task定义
        ├── test/
        ├── util/
        └── worker/ // task处理

注:apiserver模块是通用的rpc模块,此处参考bilibili sniper的轻量级go业务框架:https://github.com/bilibili/sniper

2. 添加注释

注释就相当于电影的旁白,是对关键业务代码进行必要补充说明。我的原则是:能不打就不打注释,整体上需言简意赅。必须要打的注释一般有以下基本原则:

(1)业务性很强的代码块(嵌套循环、涉及复杂算法等)

(2)(导出)外部会调用的接口(Go中每个包里可导出的接口/函数)

(3)引用了外部接口/库函数(注释说明参数意义、返回值等)

(4)对数据格式的补充说明(如处理log数据,需要给出一个log数据示例)

(5)对特殊/特例情况(如特殊判断条件)、背景信息的补充说明

(6)对TODO、版本兼容性、issue或维护者的相关说明

3. 注释代码块

随着项目的不断迭代、上线,总会存在一些被注释掉的代码。这其中有些可能是临时的(或由于需求调整或者外部接口变动),而更多的是随着版本迭代遗留下的垃圾代码。这对于后面接手的人来说简直想骂娘。对于需要注释掉的代码,我的基本原则是:

(1)import包中的注释代码须直接去掉

(2)被注释掉的简单调试代码须直接去掉(如fmt.Print、log.Info之类的)

(3)项目初期(从0到1版本)不合适的注释代码应直接去掉(实际上架构设计层面也不建议考虑版本兼容方面的问题)

(4)项目后期(版本稳定发布后)对于兼容性的注释代码建议保留

(5)对于接口/函数中靠前的条件判断或者return之前对结果的修饰的注释代码块建议保留

(6)在循环中的条件判断的注释代码块建议保留

【更新】关于代码打注释的问题,也可以参考下这篇有趣但有深度的文章:《一场关于代码注释的争执,引发的三点思考》

4. 关于空行

空行是存在价值的,但不能随意挥霍。恰当好处的空行,不仅能让读者易于理解代码逻辑,不易产生视觉疲劳,更是体现作者(模块化)思维和匠心。通常以下情况建议空行:

(1)在方法的 return、break、continue 这种断开性语句后必须是空行

(2)在不同语义块之间空行

(3)循环之前和之后一般有空行

5. 代码书写风格

(1)单行一般不超过IDE通常设置的80字符

(2)部分函数定义处考虑方便看到参数和返回值,可不考虑(1)的规范,但应控制在120之内

(2)函数体的长度最好不超过一屏(控制在60/90行之间)

6. package命名

包的名称应该简洁明了而易于理解。基本遵从以下规范:

(1)包应当以小写的单数来命名,且不应使用下划线或驼峰记法

(2)保持package的名字和目录保持一致,且一个目录下只允许定义一个包

(3)尽量采取有意义的包名,简短,有意义,尽量和标准库不要冲突

(4)如有冲突,使用别名,且遵从以上从简原则

(5)不要用“common”、“util”、“shared”或“lib”等信息量不足的名称

7. import分组

如果你的包引入了三种类型的包:标准库包,程序内部包,第三方包,建议采用如下方式进行组织你的包:

import (
    "encoding/json"
    "strings"

    "myproject/models"
    "myproject/controller"
    "myproject/utils"

    "github.com/jasonlvhit/gocron"
    "github.com/go-sql-driver/mysql"
)

有顺序的引入包,不同的类型采用空格分离,第一种实标准库,第二是项目包,第三是三方包。如果程序包名称与导入路径的最后一个元素不匹配,则必须使用导入别名:

import (
  "net/http"

  client "example.com/client-go"
  trace "example2.com/trace/v2"
)

在所有其他情况下,除非导入之间有直接冲突,否则应避免导入别名。

8. 变量命名

避免与关键字冲突或容易引起视觉看错的情况,尽量保持简洁易懂(Go标准库中存在很多的单字母简写范例)。和结构体类似,变量名称一般遵循驼峰法,首字母根据访问控制原则大写或者小写,但遇到特有名词时,需要遵循以下规则:

(1)如果变量为私有,且特有名词为首个单词,则使用小写,如 apiClient

(2)其它情况都应当使用该名词原有的写法,如 APIClient、repoID、UserID

(3)错误示例:UrlArray,应该写成 urlArray 或者 URLArray

(4)若变量类型为 bool 类型,则名称应以 Has, Is, Can 或 Allow 开头

9. 常量命名

(1)对于全局宏定义常量,可使用全部大写字母组成。也可使用驼峰首字母大写形式,并根据业务属性分组const块定义:

const (
    CONF_PATH = "./apiserver.yaml"
    PAGE_SIZE = 1024
)

const (
    StatusRun    = "run"
    StatusStop   = "stop"
    StatusRevoke = "revoke"
    StatusDelete = "delete"
)

(2)对于未导出的顶层常量和变量,使用_作为前缀

在未导出的顶级varsconsts, 前面加上前缀_,以使它们在使用时明确表示它们是全局符号(如果使用通用名称可能很容易在其他文件中意外使用错误的值)。其作用域局限在该包范围内。在业务代码中一般不推荐使用,只有在底层类库中才使用这类命名。

// Bad:
// foo.go
const (
  defaultPort = 8080
  defaultUser = "user"
)
// Good:
// foo.go
const (
  _defaultPort = 8080
  _defaultUser = "user"
)

//
// bar.go
func Bar() {
  defaultPort := 9090
  ...
  fmt.Println("Default port", defaultPort)

  // We will not see a compile error if the first line of
  // Bar() is deleted.
}

10. 变量声明

变量名采用驼峰标准(驼峰长度最好不超过5个单词,字符长度最好不超过30),多个变量申明按类型分组放在一起。在函数外部申明必须使用var,不要采用(:=)。如果将变量明确设置为某个值,则应使用短变量声明形式 (:=):

// Bad:
var s = "foo"

// Good:
s := "foo"

在某些情况下,var 使用关键字时默认值会更清晰:

// Bad:
func f(list []int) {
  filtered := []int{}
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}

// Good:
func f(list []int) {
  var filtered []int
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}

11. 共享变量

在高并发的情况下,很容易查询读写问题,故这里定义一定的使用规范:

(1)变量应该尽量避免共享,尽量通过接口的形式提供读写接口,并考虑加锁保护。

(2)一处写多处读,也需要加读写锁。

(3)依赖通讯来实现共享内存,而不是依赖共享内存来实现通讯。

12. 参数传递

(1)当函数内部需要修改接受者,必须使用指针传递

(2)当接受者是map, chan, func, 不要使用指针传递,因为它们本身就是引用类型

(3)当接受者是slice,而函数内部不会对slice进行切片或者重新分配空间,不要使用指针传递

(4)当接受者是一个结构体,并且包含了sync.Mutex或者类似的用于同步的成员。必须使用指针传递,避免成员拷贝

(5)当接受者类型是一个结构体并且很庞大,或者是一个大数组,建议使用指针传递来提高性能,其他场景使用值传递即可.

13. 结构体中的变量声明

如果你使用结构体指针,mutex 可以非指针形式作为结构体的组成字段,或者更好的方式是直接嵌入到结构体中。 如果是私有结构体类型或是要实现 Mutex 接口的类型,我们可以使用嵌入 mutex 的方法:

// 非导出型(为私有类型或需要实现互斥接口的类型嵌入):
type smap struct {
  sync.Mutex // only for unexported types(仅适用于非导出类型)

  data map[string]string
}

func newSMap() *smap {
  return &smap{
    data: make(map[string]string),
  }
}

func (m *smap) Get(k string) string {
  m.Lock()
  defer m.Unlock()

  return m.data[k]
}

// 导出型:
type SMap struct {
  mu sync.Mutex // 对于导出类型,请使用私有锁

  data map[string]string
}

func NewSMap() *SMap {
  return &SMap{
    data: make(map[string]string),
  }
}

func (m *SMap) Get(k string) string {
  m.mu.Lock()
  defer m.mu.Unlock()

  return m.data[k]
}

14. 业务处理中的不可信原则

实际上这也是网络安全中一条非常非常重要的准则。对于不可信的,遵从优先处理的原则。对于一个业务代码,从上到下,其可信度递增,故对于不符合条件的进行优先排除并return(而尽量不要采用else进行嵌套):

// Bad:
func (p *SomeHandler) IfRunningOrSuccess(res map[string]interface{}) bool {
    if v, exist := res["status"]; exist {
        if status, ok := v.(string); ok {
            If status != "" && (status == "Running" || status == "Succeeded") {
                return true
            }
        } else {
            return false
	}
    }
    return false
}

再次强调:前置条件,优先判断;边界检查,优先处理;一旦有错误发生,通常马上返回(尽早return);尽量减少多级嵌套。

15. 不必要的else

如果在 if 的两个分支中都设置了变量,则可以将其替换为单个 if(整体上遵从上面的不可信原则):

// Bad:
var a int
if b {
  a = 100
} else {
  a = 10
}

// Good:
a := 10
if b {
  a = 100
}

16. 减少嵌套

基本原则还是遵从上面的不可信原则,尽可能先处理错误情况/特殊情况,并尽早返回,对于多层嵌套,应采用模块化封装的形式尽量保存代码优雅。

// Bad:
for _, v := range data {
  if v.F1 == 1 {
    v = process(v)
    if err := v.Call(); err == nil {
      v.Send()
    } else {
      return err
    }
  } else {
    log.Printf("Invalid v: %v", v)
  }
}

// Good:
for _, v := range data {
  if v.F1 != 1 {
    log.Printf("Invalid v: %v", v)
    continue
  }

  v = process(v)
  if err := v.Call(); err != nil {
    return err
  }
  v.Send()
}

17. 外部接口调用

(1)遵循不可信原则进行边界检查,一旦有错误发生,尽早return

(2)采用多返回值,其中最后一个返回值最好是error类型

(3)考虑可能存在的GC处理(一般是defer close)

(4)考虑可能的默认参数设置(如timeout、context等)

(5)异常情况:必须打对应等级打log并记录堆栈错误信息,对于用户层的接口返回,避免直接返回底层敏感错误信息

18. 打log规范

一个工程化很高的项目对打log的规范也是很高很明确的。而对于Go项目,推荐使用logrus  库。对于线上环境,统一采用json格式且小写的message说明。log的类型/等级及其说明如下:

(1)debug:非常具体的信息,只能用于开发调试使用。部署到生产环境后,这个级别的信息只能保持很短的时间。这些信息只能临时存在,并将最终被关闭。要区分DEBUGTRACE会比较困难,对一个在开发及测试完成后将被删除的LOG输出,可能会比较适合定义为TRACE级别.

(2)info:重要的业务处理已经结束。在实际环境中,系统管理员或者高级用户要能理解INFO输出的信息并能很快的了解应用正在做什么。比如,一个和处理机票预订的系统,对每一张票要有且只有一条INFO信息描述 “[Who] booked ticket from [Where] to [Where]”。另外一种对INFO信息的定义是:记录显著改变应用状态的每一个action,比如:数据库更新、外部系统请求。

(3)warn:发生这个级别问题时,处理过程可以继续,但必须对这个问题给予额外关注。这个问题又可以细分成两种情况:

第一种是存在严重的问题但有应急措施(比如数据库不可用,使用Cache);

第二种是潜在问题及建议(如循环中批处理,其中部分错误应warn,并及时continue)。

比如生产环境的应用运行在Development模式下、管理控制台没有密码保护等。系统可以允许这种错误的存在,但必须及时做跟踪检查用户参数错误。可以使用warn日志级别来记录用户输入参数错误的情况,避免用户投诉时,无所适从。

(4)error:系统中发生了非常严重的问题,必须马上有人进行处理。没有系统可以忍受这个级别的问题的存在。比如:NPEs(空指针异常),数据库不可用,关键业务流程中断等。

log的格式化:%s、%f、%d、%v、%+v、%#v

对于调试代码,不允许使用fmt之类的,而是使用log函数。建议使用format格式化而不是拼凑:

// Bad:
fmt.Printf("add uer err:", err)

// Bad:
logger.Debug("Pod ", podName, " deleted successfully.")   

// Good:
logger.Debugf("pod(%s) deleted successfully.", podName)

19. 关于error的使用

推荐统一使用fmt.Errorf(),这样便于格式化。有指定信息的(最后的error类型除外)使用英文的括号。error的消息体使用小写格式,保持简单明了,并附带相关会话/场景的数据信息。对于通用的简单error类型可在文件头部封装定义:

// 通过简单的error可全局定义出来(使用errors.New())
var (
    ErrorResultType = errors.New("error result type")
    ErrNotFound = errors.New("error not found")
    ErrIsDuplicate = errors.New("error duplicate key")
) 

// Bad: 
fmt.Errorf("Json's unmarshal failed. err : %v", err) // 消息体不够简洁

// Good: 
fmt.Errorf("create user(%s) err: %v", name, err)

对于(在接口层)需要返回给外部调用者的error,建议对error进行封装(可以参考grpc的status.Error和grpc.odes的设计实现)。且返回给外部的error需要重新定义(不要将底层的堆栈错误信息返回给外部调用者)。

至此,关于Go语言的基础编码规范就写的差不多了,后面有时间再总结下关于技巧性的经验分享。最后,就用一个GopherChina 2017中关于Go开发设计风格的演讲Go coding in go way结尾吧:

演稿文章: https://tonybai.com/2017/04/20/go-coding-in-go-way/

演讲视频: https://www.youtube.com/watch?v=N4deJtxeNKM



这篇博文由 s0nnet 于2020年03月20日发表在 Go语言, 代码艺术, 工程能力 分类下, 通告目前不可用,你可以至底部留下评论。
如无特别说明,计算机技术分享发表的文章均为原创,欢迎大家转载,转载请注明: Go语言开发规范 | 计算机技术分享

Go语言开发规范:目前有1 条留言

  1. 0楼
    money:

    夏天快乐,
    感谢博主的分享,支持了。文章真长

    2021-07-17 下午7:45 [回复]

发表评论

快捷键:Ctrl+Enter