Go 编码规范建议

Posted 恋喵大鲤鱼

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go 编码规范建议相关的知识,希望对你有一定的参考价值。


0.前言

本文结合自身项目经验,尽可能以简短的语言给出一套行之有效 Go 编码规范建议,让您的代码健壮易读。

各个公司或机构,都有各自不同的规范,但大同小异。规范没有绝对正确,更重要的是大家要遵守规范。

本文所述内容均为参考意见,并非标准。其中许多是 Go 的通用准则,而其他扩展准则依赖于下面官方指南:

1.项目结构

项目布局建议遵守业界规范 Standard Go Project Layout

一个推荐的项目结构如下:

# Go 目录
|--cmd 可执行文件目录
|--internal 私有库代码,仅本项目使用
|--pkg 公有库代码,外部项目可以使用
|--vendor 外部依赖库

# 服务应用程序目录
|--api 服务对外接口

# Web 应用程序目录
|--web 特定于 Web 应用程序的组件:静态 Web 资产、服务器端模板和 SPAs

# 通用应用目录
|--configs 配置信息
|--init 启动信息
|--scripts 执行各种构建、安装、分析等操作的脚本
|--build 打包和持续集成
   |-- package 将你的云( AMI )、容器( Docker )、操作系统( deb、rpm、pkg )包配置和脚本放在 /build/package 目录下
   |-- ci 将你的 CI (travis、circle、drone)配置和脚本放在 /build/ci 目录中
|--deployments IaaS、PaaS、系统和容器编排部署配置和模板
|--test 额外的外部测试应用程序和测试数据
|--README.md 要有一个项目说明

# 其他目录
|--docs 项目文档
|--tools 项目工具
|--examples 应用程序和公共库的示例
|--third_party 外部辅助工具,分叉代码和其他第三方工具
|--githooks Git hooks
|--assets 与存储库一起使用的其他资产(图像、徽标等)
|--website 如果你不使用 Github 页面,则在这里放置项目的网站数据

# 不应该拥有的目录
|--src 有些 Go 项目确实有一个 src 文件夹,但这通常发生在开发人员有 Java 背景。不同类型的代码应该有自己的目录,而不是全部放到 src 下面

2.代码风格

格式化

代码必须用 gofmt 格式化。

换行

一行代码不要超过120列,超过的情况,使用合理的换行方法换行。

例外场景:

  • import 模块语句
  • 工具生成代码
  • struct tag

括号和空格

  • 遵循 gofmt 的逻辑。
  • 运算符和操作数之间要留空格。
  • 作为输入参数或者数组下标时,运算符和运算数之间不需要空格,紧凑展示。

使用原始字符串字面值,避免转义

// Bad
wantError := "unknown name:\\"test\\""

// Good
wantError := `unknown error:"test"`

不必要的 else

如果在 if 的两个分支中都设置了变量,则可以将其替换为单个 if。

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

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

import 规范

  • 使用 goimports 自动格式化引入的包名,import 规范原则上以 goimports 规则为准
  • goimports 会自动把依赖包按首字母排序,并对包进行分组管理,通过空行隔开,默认分为本地包(标准库、内部包)和外部包
  • 包采用四段式组织,从上到下分别是标准库,外部包,匿名包和内部包
  • 带域名的包名都属于外部包,如 github.com/xxx/xxx
  • 匿名包的引用建议使用一个新的分组引入,并在匿名包上写上注释说明
  • 内部包是指不能被外部 import 的包
import (
    // standard package
    "encoding/json"
    "strings"
    
    // third-party package
    "git.obc.im/obc/utils"
    "git.obc.im/dep/beego"
    "git.obc.im/dep/mysql"
    elastic "github.com/olivere/elastic/v7"

    // anonymous package
    // import filesystem storage driver
    _ "git.code.oa.com/org/repo/pkg/storage/filesystem

	// inner package
    "myproject/models"
    "myproject/controller"
)
  • 不要使用相对路径引入包,应该使用完整的路径引入包:
// 不要采用这种方式
import (
    "../net"
)

// 应该采用完整路径引入包
import (
    "xxxx.com/proj/net"
)
  • 包名和 git 路径名不一致时,或者多个相同包名冲突时,使用别名代替:
import (
	elastic "github.com/olivere/elastic/v7"
)

使用 time 处理时间

时间处理很复杂。关于时间的错误假设通常包括以下几点。

  • 一天有 24 小时
  • 一小时有 60 分钟
  • 一周有七天
  • 一年 365 天
  • 还有更多

例如,在一个时间点上加上 24 小时并不总是产生一个新的日历日。

因此,在处理时间时始终使用 “time” 包,因为它有助于以更安全、更准确的方式处理这些不正确的假设。

  • 使用 time.Time 表达瞬时时间

在处理时间的瞬间时使用 time.Time,在比较、添加或减去时间时使用 time.Time 中的方法。

// Bad
func isActive(now, start, stop int) bool {
  return start <= now && now < stop
}

// God
func isActive(now, start, stop time.Time) bool {
  return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}
  • 使用 time.Duration 表达时间段
// Bad
func poll(delay int) {
  for {
    // ...
    time.Sleep(time.Duration(delay) * time.Millisecond)
  }
}
poll(10) // 是几秒钟还是几毫秒?

// Good
func poll(delay time.Duration) {
  for {
    // ...
    time.Sleep(delay)
  }
}
poll(10*time.Second)
  • 对外部系统使用 time.Time 和 time.Duration

尽可能在与外部系统交互中使用 time.Durationtime.Time,例如:

  • Command-line 标志: flag 通过 time.ParseDuration 支持 time.Duration
  • JSON: encoding/json 通过其 UnmarshalJSON method 方法支持将 time.Time 编码为 RFC 3339 字符串
  • SQL: database/sql 支持将 DATETIMETIMESTAMP 列转换为 time.Time,如果底层驱动程序支持则返回
  • YAML: gopkg.in/yaml.v2 支持将 time.Time 作为 RFC 3339 字符串,并通过 time.ParseDuration 支持 time.Duration。

当不能在这些交互中使用 time.Duration 时,请使用 intfloat64,并在字段名称中包含单位。

例如,由于 encoding/json 不支持 time.Duration,因此该单位包含在字段的名称中。

// Bad
// {"interval": 2}
type Config struct {
  Interval int `json:"interval"`
}

// Good
// {"intervalMillis": 2000}
type Config struct {
  IntervalMillis int `json:"intervalMillis"`
}

避免在公共结构中嵌入类型

嵌入类型会泄漏实现细节、禁止类型演化、产生模糊的文档,应该尽可能地避免。

// Bad
// ConcreteList 是一个实体列表。
type ConcreteList struct {
  *AbstractList
}

// Good
// ConcreteList 是一个实体列表
type ConcreteList struct {
  list *AbstractList
}
// 添加将实体添加到列表中
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}
// 移除从列表中移除实体
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

无论是使用嵌入式结构还是使用嵌入式接口,嵌入式类型都会限制类型的演化。

  • 向嵌入式接口添加方法是一个破坏性的改变。
  • 删除嵌入类型是一个破坏性的改变。
  • 即使使用满足相同接口的替代方法替换嵌入类型,也是一个破坏性的改变。

尽管编写这些委托方法是乏味的,但是额外的工作隐藏了实现细节,留下了更多的更改机会,还消除了在文档中发现完整列表接口的间接性操作。

3.初始化

初始化 struct

  • 使用字段名初始化结构体
// Bad
k := User{"John", "Doe", true}

// Good
k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}

例外:如果有 3 个或更少的字段,则可以在测试表中省略字段名称。

tests := []struct{
  op Operation
  want string
}{
  {Add, "add"},
  {Subtract, "subtract"},
}
  • 省略结构中的零值字段
// Bad
user := User{
  FirstName: "John",
  LastName: "Doe",
  MiddleName: "",
  Admin: false,
}

// Good
user := User{
  FirstName: "John",
  LastName: "Doe",
}

例外:在字段名提供有意义上下文的地方包含零值。例如,表驱动测试中的测试用例可以受益于字段的名称,即使它们是零值的。

tests := []struct{
  give string
  want int
}{
  {give: "0", want: 0},
  // ...
}
  • 声明零值结构使用 var
// Bad
var user := User{}

// Good
var user User
  • 初始化 Struct 引用

在初始化结构引用时,请使用&T{}代替new(T),以使其与结构体初始化一致。

// Bad
sval := T{Name: "foo"}

// inconsistent
sptr := new(T)
sptr.Name = "bar"

// Good
sval := T{Name: "foo"}

sptr := &T{Name: "bar"}

初始化 map

  • 对于空 map 请使用make(..)初始化。
// Bad
var (
  // m1 读写安全;
  // m2 在写入时会 panic
  m1 = map[T1]T2{}
  m2 map[T1]T2
)

// Good
var (
  // m1 读写安全;
  // m2 在写入时会 panic
  m1 = make(map[T1]T2)
  m2 map[T1]T2
)
  • 试用初始化列表初始化包含固定的元素 map
// Bad
m := make(map[T1]T2, 3)
m[k1] = v1
m[k2] = v2
m[k3] = v3

// Good
m := map[T1]T2{
  k1: v1,
  k2: v2,
  k3: v3,
}

初始化 slice

  • 非零值 slice 使用make()初始化,并指定容量
// Bad
nums := []int{}

// Good
nums := make([]int, 0, SIZE)
  • 零值切片(用 var 声明的切片)可立即使用,无需调用make()创建
// Bad
// 非 nil 切片
nums := []int{}

// Good
// nil 切片
var nums []int

变量申明

  • 本地变量声明应使用短变量声明形式(:=
// Bad
var s = "foo"

// Good
s := "foo"
  • 尽量缩小变量作用范围。
// Bad
err := ioutil.WriteFile(name, data, 0644)
if err != nil {
 return err
}

// Good
if err := ioutil.WriteFile(name, data, 0644); err != nil {
 return err
}

避免使用 init()

尽可能避免使用 init()。当init()是不可避免或可取的,代码应先尝试:

  • 无论程序环境或调用如何,都要完全确定。
  • 避免依赖于其他 init() 函数的顺序或副作用。虽然 init() 顺序是明确的,但代码可以更改, 因此 init() 函数之间的关系可能会使代码变得脆弱和容易出错。
  • 避免访问或操作全局或环境状态,如机器信息、环境变量、工作目录、程序参数/输入等。
  • 避免I/O,包括文件系统、网络和系统调用。

4.错误处理

error 处理

  • error 作为函数的值返回,必须对 error 进行处理,或将返回值赋值给明确忽略。对于defer xx.Close()可以不用显式处理
  • error 作为函数的值返回且有多个返回值的时候,error 必须是最后一个参数
// 不要采用这种方式
func do() (error, int) {
}

// 要采用下面的方式
func do() (int, error) {
}
  • 采用独立的错误流进行处理。
// 不要采用这种方式
if err != nil {
    // error handling
} else {
    // normal code
}

// 而要采用下面的方式
if err != nil {
    // error handling
    return // or continue, etc.
}
// normal code
  • 如果函数返回值需用于初始化其他变量,则采用下面的方式:
x, err := f()
if err != nil {
    // error handling
    return // or continue, etc.
}
// use x
  • 错误返回的判断独立处理,不与其他变量组合逻辑判断。
// 不要采用这种方式:
x, y, err := f()
if err != nil || y == nil {
    return err   // 当y与err都为空时,函数的调用者会出现错误的调用逻辑
}

// 应当使用如下方式:
x, y, err := f()
if err != nil {
    return err
}
if y == nil {
    return fmt.Errorf("some error")
}
  • 带参数的 error 生成方式为:fmt.Errorf("module xxx: %v", err),而不是 errors.New(fmt.Sprintf("module xxx: %v",err))

panic 处理

  • 在业务逻辑处理中禁止使用 panic
  • 在 main 包中只有当完全不可运行的情况可使用 panic,例如:文件无法打开,数据库无法连接导致程序无法正常运行
  • 对于其它的包,可导出的接口一定不能有 panic
  • 在包内传递错误时,不推荐使用 panic 来传递 error
// 不推荐为传递 error 而在包内使用 panic。以下为反面示例

// TError 包内定义的错误类型
type TError string

// Error error接口方法
func (e TError) Error() string {
    return string(e)
}

func do(str string) {
    // ...
    // 此处的 panic 用于传递 error
    panic(TError("错误信息"))
    // ...
}

// Do 包级访问入口
func Do(str string) (err error) {
    defer func() {
        if e := recover(); e != nil {
            err = e.(TError)
        }
    }()
    do(str)
    return nil
}
  • 建议在 main 包中使用 log.Fatal 来记录错误,这样就可以由 log 来结束程序,或者将 panic 抛出的异常记录到日志文件中,方便排查问题
  • panic 捕获只能到 goroutine 最顶层,每个自行启动的 goroutine,必须在入口处捕获 panic,并打印详细堆栈信息或进行其它处理

recover 处理

  • recover 用于捕获 runtime 的异常,禁止滥用 recover
  • 必须在 defer 中使用,一般用来捕获程序运行期间发生异常抛出的 panic 或程序主动抛出的 panic
package main

import (
    "log"
)

func main() {
    defer func() {
        if err := recover(); err != nil {
            // do something or record log
            log.Println("exec panic error: ", err)
            // log.Println(debug.Stack())
        }
    }()
    
    getOne()
    
    panic(44) //手动抛出panic
}

// getOne 模拟 slice 越界 runtime 运行时抛出的 panic
func getOne() {
    defer func() {
        if err := recover(); err != nil {
            // do something or record log
            log.Println("exec panic error: ", err)
            // log.Println(debug.Stack())
        }
    }()
    
    var arr = []string{"a", "b", "c"}
    log.Println("hello,", arr[4])
}

// 执行结果:
2021/10/04 11:07:13 exec panic error:  runtime error: index out of range [4] with length 3
2021/10/04 11:07:13 exec panic error:  44

类型断言失败处理

  • type assertion 的单个返回值形式针对不正确类型将产生 panic。因此,请始终使用 “comma ok” 惯用法。
// 不要采用这种方式
t := i.(string)

// 而要采用下面的方式
t, ok := i.(string)
if !ok {
    // 优雅地处理错误
}

5.性能

优先使用 strconv 而不是 fmt

将原语转换为字符串或从字符串转换时,strconvfmt快。

// Bad
// BenchmarkFmtSprint-4    143 ns/op    2 allocs/op
for i := 0; i < b.N; i++ {
  s := fmt.Sprint(rand.Int())
}

// Good
// BenchmarkStrconv-4    64.2 ns/op    1 allocs/op
for i := 0; i < b.N; i++ {
  s := strconv.Itoa(rand.Int())
}

指定 slice 容量

在尽可能的情况下,在使用make()初始化切片时提供容量信息,特别是在追加切片时。

// Bad
// BenchmarkBad-4    100000000    2.48s
for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}

// Good
// BenchmarkGood-4   100000000    0.21s
for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}

指定 map 容量

make()提供容量提示会在初始化时尝试调整map的大小,这将减少在将元素添加到map时为map重新分配内存。

注意,与 slice 不同。map capacity 提示并不保证完全的抢占式分配,而是用于估计所需的 hashmap bucket 的数量。 因此,在将元素添加到 map 时,甚至在指定 map 容量时,仍可能发生分配。

make(map[T1]T2, hint)

6.注释

在编码阶段同步写好类型、变量、函数、包注释,注释可以通过godoc导出生成文档。

程序中每一个被导出的(大写的)名字,都应该有一个文档注释。

所有注释掉的代码在提交 code review 前都应该被删除,除非添加注释讲解为什么不删除, 并且标明后续处理建议(比如删除计划)。

包注释

  • 每个包都应该有一个包注释。
  • 包如果有多个 go 文件,只需要出现在一个 go 文件中(一般是和包同名的文件)即可,格式为:“// Package 包名 包信息描述”。
// Package math provides basic constants and mathematical functions.
package math

// 或者

/*
Package template implements data-driven templates for generating textual
output such as html.
....
*/
package template

函数注释

  • 每个需要导出的函数或者方法(结构体或者接口下的函数称为方法)都必须有注释。注意,如果方法的接收器为不可导出类型,可以不注释,但需要质疑该方法可导出的必要性。
  • 注释描述函数或方法功能、调用方等信息。
  • 格式为:"// 函数名 函数信息描述"。
// NewtAttrModel 是属性数据层操作类的工厂方法
func NewAttrModel(ctx *common.Context) *AttrModel {
    // TODO
}
  • 避免参数语义不明确

函数调用中的意义不明确的参数可能会损害可读性。当参数名称的含义不明显时,请为参数添加 C 样式注释 (/* ... */)

// Bad
// func printInfo(name string, isLocal, done bool)
printInfo("foo", true, true)

// Good 
// func printInfo(name string, isLocal, done bool)
printInfo("foo", true /* isLocal */, true /* done */)

结构体注释

  • 每个需要导出的自定义结构体或者接口都必须有注释说明。
  • 注释对结构进行简要介绍,放在结构体定义的前一行。
  • 格式为:"// 结构体名 结构体信息描述"。
  • 结构体内的可导出成员变量名,如果是个生僻词或意义不明确的词,必须要单独给出注释,放在成员变量的前一行或同一行的末尾。
// User 用户结构定义了用户基础信息
type User struct {
    Name  string
    Email string
    Demographic string // 族群
}

变量和常量注释

  • 每个需要导出的常量和变量都必须有注释说明。
  • 注释对常量或变量进行简要介绍,放在常量或变量定义的前一行。
  • 大块常量或变量定义时,可在前面注释一个总的说明,然后每一行常量的末尾详细注释该常量。
  • 独行注释格式为:"// 变量名 描述",斜线后面紧跟一个空格。
// FlagConfigFile 配置文件的命令行参数名
const FlagConfigFile = "--config"

// 命令行参数
Go 编码规范建议

Go 编码规范建议

GO 编码规范

Go 语言编程规范

[Go] 编码规范

golang编码规范