[TOC]
0x00 前言简述 本章主要对 Go 语言开发规范进行记录与实践, 便于养成良好的开发习惯也可叫做规则(不至于进入一些大厂而因为开发习惯没养成而痛苦),规则的存在是为了使代码库易于管理,同时仍然允许工程师更有效地使用 Go 语言功能.
在Go语言为我们提供众多的工具来检测我们开发规范, 例如所有代码都应该通过golint
和go vet
的检查并无错误。
Go 编程语言规范 (https://golang.org/ref/spec ) 版本 Jul 26, 2021
Go 的通用准则可查看官方提供的参考指南:
Effective Go
Go Common Mistakes
Go Code Review Comments
第三方公司Go开发规范参考: https://github.com/uber-go/
0x01 Go开发规范 命名规范
只可以使用字母、下划线、数字
大写字母开头,可以被包外部引用
(需要先导入包)
小写字母开头,只可以被包内部调用
目录&package 包命名 尽量保持package的名字和目录一致,采取有意义的包(简短而简洁)名,包名使用小写,不要使用下划线和大写字母 ,不用复数,例如
1 2 3 4 5 6 // package [按照类别命名] package main // 表示这个是字符串编码的包,即该.go文件应该包含在 strencode 目录下。 // 包命名名称最好不加s复数,即不能strencodes package strencode
.go 文件命名 使用小写字母,可以用下划线分割,如果是测试文件或示例文件应该以_test.go
结尾1 2 3 4 5 6 // 打印功能实现文件 print.go // 单元测试或基准测试文件 print_test.go // 示例文件 example_print_test.go
constant-常亮命名 全部大写,并以_分割1 2 3 4 5 6 7 8 // 单一常量声明 const PI = 3.1415926535898 // 批量常量声明 const ( USER_NAME = "WeiyiGeek" USER_ADDR = "ChongQing.China" )
variable-变量命名 一般为驼峰命名,遵循以下规则
变量为私有,首字母小写
变量为公有。首字母大写
单词为特有名次,而且是首个单词,则特有名词小写
若变量为布尔类型,则名称一般以”Has”、”Is”、”Can”、”Allow”开头
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 var Public = "public" var private = "private" var userName string = "weiyigeek" briefCount := 65535 var ( userName string userAge int8 user_sex bool )
function-函数命名 采用驼峰命名法,注意特殊的匿名函数,以及单元测试函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func getName (id int ) (name string ) {}func GetAge (id int ) (age uint8 ) {}func (u *User) Printf (id int ) (name string ) {}func () {fmt.Println("匿名函数无函数名称" )}()fun TestMyFunction_WhatIsBeingTested(t *test.T){}
struct-结构体命名 采用驼峰命名法,struct 声明和初始化用多行,特别注意匿名结构体。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 type User struct { Username string Email string } type user struct { Username string Email string } u := User{ Username: "ada" Email: "dsfgsdfg" } type anonymousStr struct { Username string Email string }
interface-接口命名 命名规范基本和结构体一致, 但是单个函数习惯以”er”为后缀。
1 2 3 type Reader interface { Read(p []byte ) (n int ,err error) }
注释规范 Go语言中注释符号如下:
单行: // 注释文本
多行: /* 注释文本 */
Tips: 多行注释中可以嵌套单行注释。
Tips: 注释符//
后面要加空格, 例如:// 注释文本
,并且有效的关键字注释不应该超过3
行
Tips: 如果当前包目录下包含多个 Package
注释的 go 文件(包括doc.go
), 那么按照 文件名的字母数序
优先显示
Tips: Package
的注释会出现在godoc的 包列表 中, 但只能展示大约523字节的长度
注释使用的范围:
注释示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 package notesconst XYZ = 1 var xyz = 1 type Abc struct {} func Bcd () {}type Abcer interface {}func Example_notes () { fmt.Println("Hello OverView" ) }
Tips :注释中的URL将会变成HTML链接。
样式规范 缩进与括号
go语言默认每一行都会加一个;
所以前置大括号不能单独一样
go可以使用go自带的fmt工具格式化代码,vscode 可以直接安装go项目工具,在你保存时将会自动格式化。
尽量使用Tab
,而不是空格
代码一致性 一致性的代码更容易维护、是更合理的、需要更少的学习成本、并且随着新的约定出现或者出现错误后更容易迁移、更新、修复 bug
相反,在一个代码库中包含多个完全不同或冲突的代码风格会导致维护成本开销、不确定性和认知偏差。所有这些都会直接导致速度降低、代码审查痛苦、而且增加 bug 数量。
将这些标准应用于代码库时,建议在 package(或更大)级别进行更改,子包级别的应用程序通过将多个样式引入到同一代码中,违反了上述关注点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 const ( a = 1 b = 2 ) var ( a = 1 b = 2 ) type ( Area float64 Volume float64 ) type Operation int const ( Add Operation = iota + 1 Subtract Multiply ) const EnvVar = "MY_ENV" func f () string { var ( red = color.New(0xff 0000) green = color.New(0x00ff 00) blue = color.New(0x0000ff ) ) }
Import (包导入) 引入多个包时,按照三中类型区分,标准包,程序内部包,第三方包,建议写的时候有顺序的导入你的包。
默认情况下,这是 goimports 应用的分组
1 2 3 4 5 6 7 import ( "fmt" "os" "go.uber.org/atomic" "golang.org/x/sync/errgroup" )
导入别名
如果程序包名称与导入路径的最后一个元素不匹配,则必须使用导入别名。
1 2 3 4 5 6 7 8 9 10 11 12 import ( "fmt" "os" "runtime/trace" "net/http" _ "net/http/pprof" client "example.com/client-go" trace "example.com/trace/v2" )
Function (函数) (1) 函数分组与顺序
在进行Go语言时函数应按粗略的调用顺序排序,同一文件中的函数应按接收者分组。
因此,导出的函数应先出现在文件中,放在struct, const, var
定义的后面。
在定义类型之后,但在接收者的其余方法之前,可能会出现一个newXYZ()/NewXYZ()
由于函数是按接收者分组的,因此普通工具函数应在文件末尾出现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type something struct { ... } func newSomething () *something { return &something{} } func (s *something) Cost () { return calcCost(s.weights) } func (s *something) Stop () {...} func calcCost (n []int ) int {...}
(2) 减少不必要的嵌套以及else
描述: 代码应通过尽可能先处理错误情况/特殊情况
并尽早返回或继续循环来减少嵌套,减少嵌套多个级别的代码的代码量。
如果在 if 的两个分支中都设置了变量,则可以将其替换为单个 if。
weiyigeek.top-减少不必要的嵌套以及else
0x02 指导原则 Variable (变量) 顶层变量声明 描述: 在顶层,使用标准var
关键字。请勿指定类型,除非它与表达式的类型不同。
1 2 3 4 5 6 7 // 不推荐的方式 var _s string = F() func F() string { return "A" } // 推荐方式,由于 F 已经明确了返回一个字符串类型,因此我们没有必要显式指定_s 的类型, 还是那种类型 var _s = F() func F() string { return "A" }
如果表达式的类型与所需的类型不完全匹配,请指定类型
。
1 2 3 4 5 type myError struct{} func (myError) Error() string { return "error" } func F() myError { return myError{} } var _e error = F() // F 返回一个 myError 类型的实例,但是我们要 error 类型
对于未导出的顶层常量和变量,使用_作为前缀 描述: 在未导出的顶级vars
和consts
, 前面加上前缀_,以使它们在使用时明确表示它们是全局符号。
例外:未导出的错误值,应以err
开头。
基本依据:顶级变量和常量具有包范围作用域,使用通用名称可能很容易在其他文件中意外使用错误的值。
1 2 3 4 5 const ( _defaultPort = 8080 _defaultUser = "user" )
本地变量声明 描述: 如果将变量明确设置为某个值,则应使用短变量声明形式 (:=
) ,例如s := "foo"
。
但是,在某些情况下,var
使用关键字时默认值会更清晰。例如,声明空切片。
1 2 3 4 5 6 7 8 func f (list []int ) { var filtered []int for _, v := range list { if v > 10 { filtered = append (filtered, v) } } }
缩小变量作用域 描述: 如果有可能,尽量缩小变量作用范围,除非它与 减少嵌套 的规则冲突。
1 2 3 if err := ioutil.WriteFile(name, data, 0644); err != nil { return err }
如果需要在 if 之外使用函数调用的结果,则不应尝试缩小范围。
1 2 3 4 5 6 7 8 9 10 data, err := ioutil.ReadFile(name) if err != nil { return err } if err := cfg.Decode(data); err != nil { return err } fmt.Println(cfg) return nil
避免可变全局变量 描述: 使用选择依赖注入方式避免改变全局变量,既适用于函数指针又适用于其他值类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 type signer struct { now func () time .Time } func newSigner () *signer { return &signer{ now: time.Now, } } func (s *signer) Sign (msg string ) string { now := s.now() return signWithTime(msg, now) } func TestSigner (t *testing.T) { s := newSigner() s.now = func () time .Time { return someFixedTime } assert.Equal(t, want, s.Sign(give)) }
避免使用内置名称 在Go语言规范概述了几个内置的 ,不应在Go项目中使用的名称标识(Go 编程语言规范 - go.dev )
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 break default func interface select case defer go map struct chan else goto package switch const fallthrough if range type continue for import return var // # 预先声明的标识符,以下标识符在宇宙块中隐式声明: Types : bool byte complex64 complex128 error float32 float64 int int8 int16 int32 int64 rune string uint uint8 uint16 uint32 uint64 uintptr Constants : true false iota Zero value : nil Functions : append cap close complex copy delete imag len make new panic print println real recover
根据上下文的不同,将这些标识符作为名称重复使用,将在当前作用域(或任何嵌套作用域)中隐藏原始标识符,或者混淆代码。
在最好的情况下,编译器会报错;在最坏的情况下,这样的代码可能会引入潜在的、难以恢复的错误。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 示例1 var errorMessage string // 不要使用关键字 error 作用域将被隐式覆盖 // 此时 `errorMessage` 指向内置的非覆盖 // or func handleErrorMessage(msg string) { // 此时 `errorMessage` 指向内置的非覆盖 } // 示例2 type Foo struct { // `error` and `string` 现在是明确的。 err error // 不要使用 error 名称作为结构体元素 str string // 不要使用 string 名称作为结构体元素 } func (f Foo) Error() error { return f.err } func (f Foo) String() string { return f.str }
注意,编译器在使用预先分隔的标识符时不会生成错误,但是诸如go gofmt 与go vet
之类的工具会正确地指出这些和其他情况下的隐式问题。
使用原始字符串字面值,避免转义 描述: Go 支持使用 原始字符串字面值 ,也就是 “ ` “ 来表示原生字符串,在需要转义的场景下,我们应该尽量使用这种方案来替换。
例如,可以跨越多行并包含引号。使用这些字符串可以避免更难阅读的手工转义的字符串。
1 wantError := `unknown error:"test"`
Struct (结构体)
使用字段名初始化结构体 描述: 初始化结构体时,应该指定字段名称,现在由 go vet
强制执行。
例外:如果有 3 个或更少的字段,则可以在测试表中省略字段名称。
1 2 3 4 5 6 7 tests := []struct { op Operation want string }{ {Add, "add" }, {Subtract, "subtract" }, }
省略结构中的零值字段
描述: 初始化具有字段名的结构时,除非提供有意义的上下文,否则忽略值为零的字段。 也就是,让我们自动将这些设置为零值,这有助于通过省略该上下文中的默认值来减少阅读的障碍,只指定有意义的值。
1 2 3 4 user := User{ FirstName: "John", LastName: "Doe", }
在字段名提供有意义上下文的地方包含零值。例如,表驱动测试 中的测试用例可以受益于字段的名称,即使它们是零值的。
1 2 3 4 5 6 7 tests := []struct{ got string // give want int }{ {got: "0", want: 0}, // ... }
对零值结构使用 var
描述: 如果在声明中省略了结构的所有字段,请使用 var
声明结构,例如var user User
。
这将零值结构与那些具有类似于为[初始化 Maps]创建的,区别于非零值字段的结构区分开来,并与我们更喜欢的 declare empty slices 方式相匹配。
初始化 Struct 引用
描述: 在初始化结构引用时,请使用&T{}
代替new(T)
,以使其与结构体初始化一致。
1 2 sval := T{Name: "foo" } sptr := &T{Name: "bar" }
结构体中的嵌入 描述: 嵌入式类型(例如 mutex
)应位于结构体内的字段列表的顶部,并且必须有一个空行将嵌入式字段与常规字段分隔开。
内嵌应该提供切实的好处,比如以语义上合适的方式添加或增强功能,它应该在对用户不利影响的情况下完成这项工作。
结构体中的嵌入不应该是以下几个方面:
纯粹是为了美观或方便。
使外部类型更难构造或使用。
影响外部类型的零值。如果外部类型有一个有用的零值,则在嵌入内部类型之后应该仍然有一个有用的零值。
作为嵌入内部类型的副作用,从外部类型公开不相关的函数或字段。
公开未导出的类型。
影响外部类型的复制形式。
更改外部类型的API或类型语义。
嵌入内部类型的非规范形式。
公开外部类型的实现详细信息。
允许用户观察或控制类型内部。
通过包装的方式改变内部函数的一般行为,这种包装方式会给用户带来一些意料之外情况。
简单地说,有意识地和有目的地嵌入,一种很好的测试体验是,”是否所有这些导出的内部方法/字段都将直接添加到外部类型”。 如果: 答案是some
或no
,不要嵌入内部类型而是使用字段。
避免在公共结构中嵌入类型 描述: 嵌入的类型泄漏实现细节、禁止类型演化和模糊的文档。
假设,您使用共享的 AbstractList
实现了多种列表类型,请避免在具体的列表实现中嵌入 AbstractList
。
相反,只需手动将方法写入具体的列表,该列表将委托给抽象列表。
1 2 3 4 5 6 7 8 9 type AbstractList struct {}func (l *AbstractList) Add (e Entity) { } func (l *AbstractList) Remove (e Entity) { }
推荐操作:
1 2 3 4 5 6 7 8 9 10 11 12 // 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) }
Go 允许 类型嵌入 作为继承和组合之间的折衷,外部类型获取嵌入类型的方法的隐式副本。
默认情况下,这些方法委托给嵌入实例的同一方法,结构还获得与类型同名的字段,所以,如果嵌入的类型是 public,那么字段是 public。
为了保持向后兼容性,外部类型的每个未来版本都必须保留嵌入类型, 很少需要嵌入类型,这是一种方便,可以帮助您避免编写冗长的委托方法。
即使嵌入兼容的抽象列表 interface ,而不是结构体,这将为开发人员提供更大的灵活性来改变未来,但仍然泄露了具体列表使用抽象实现的细节。
1 2 3 4 5 6 7 8 9 10 11 12 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) }
无论是使用嵌入式结构还是使用嵌入式接口,嵌入式类型都会限制类型的演化.
向嵌入式接口添加方法是一个破坏性的改变。
删除嵌入类型是一个破坏性的改变。
即使使用满足相同接口的替代方法替换嵌入类型,也是一个破坏性的改变。
尽管编写这些委托方法是乏味的,但是额外的工作隐藏了实现细节,留下了更多的更改机会,还消除了在文档中发现完整列表接口的间接性操作。
功能选项 功能选项是一种模式,您可以在其中声明一个不透明 Option 类型,该类型在某些内部结构中记录信息。您接受这些选项的可变编号,并根据内部结构上的选项记录的全部信息采取行动。
将此模式用于您需要扩展的构造函数和其他公共 API 中的可选参数,尤其是在这些功能上已经具有三个或更多参数的情况下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 type Option interface { } func WithCache (c bool ) Option { } func WithLogger (log *zap.Logger) Option { } func Open ( addr string , opts ...Option, ) (*Connection, error) { }
我们建议实现此模式的方法是使用一个 Option
接口,该接口保存一个未导出的方法,在一个未导出的 options
结构上记录选项。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 type options struct { cache bool logger *zap.Logger } type Option interface { apply(*options) } type cacheOption bool func (c cacheOption) apply(opts *options) { opts.cache = bool(c) } func WithCache(c bool) Option { return cacheOption(c) } type loggerOption struct { Log *zap.Logger } func (l loggerOption) apply(opts *options) { opts.logger = l.Log } func WithLogger(log *zap.Logger) Option { return loggerOption{Log: log} } // Open creates a connection. func Open( addr string, opts ...Option, ) (*Connection, error) { options := options{ cache: defaultCache, logger: zap.NewNop(), } for _, o := range opts { o.apply(&options) } // ... }
注意: 还有一种使用闭包实现这个模式的方法,但是我们相信上面的模式为作者提供了更多的灵活性,并且更容易对用户进行调试和测试。特别是,在不可能进行比较的情况下它允许在测试和模拟中对选项进行比较。此外,它还允许选项实现其他接口,包括 fmt.Stringer
,允许用户读取选项的字符串表示形式。
Function (函数) 避免使用 init()
在Go语言开发应该避免使用init(),当必须要使用其时,代码应先尝试:
无论程序环境或调用如何,都要完全确定。
避免依赖于其他init()函数的顺序或副作用。虽然init()顺序是明确的,但代码可以更改,因此init()函数之间的关系可能会使代码变得脆弱和容易出错。
避免访问或操作全局或环境状态,如机器信息、环境变量、工作目录、程序参数/输入等。
避免I/O,包括文件系统、网络和系统调用。
Tips : 不能满足上述这些要求的代码可能属于要作为main()
调用的一部分(或程序生命周期中的其他地方), 或者作为main()
本身的一部分写入。
特别是,打算由其他程序使用的库应该特别注意完全确定性,而不是执行“init magic”
考虑到上述情况,在某些情况下,init()
可能更可取或是必要的,可能包括:
避免参数语义不明确(Avoid Naked Parameters) 描述: 函数调用中的意义不明确的参数
可能会损害可读性。当参数名称的含义不明显时,请为参数添加 C 样式注释 (/* ... */
)
1 2 printInfo("foo" , true , true )
对于上面的示例代码,还有一种更好的处理方式是将上面的 bool
类型换成自定义类型。将来,该参数可以支持不仅仅局限于两个状态(true/false
)。
1 2 3 4 5 6 7 8 9 10 11 12 13 type Region int const ( UnknownRegion Region = iota Local ) type Status int const ( StatusReady Status= iota + 1 StatusDone ) func printInfo (name string , region Region, status Status)
优雅退出方式 Exit
Go程序使用os.Exit
或者 log.Fatal*
立即退出 (使用panic
不是退出程序的好方法,请参照下面错误处理 don’t panic .)
仅在main()
中调用其中一个 os.Exit
或者 log.Fatal*
。所有其他函数应将错误返回到信号失败中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func main() { body, err := readFile(path) if err != nil { log.Fatal(err) } fmt.Println(body) } func readFile(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } b, err := ioutil.ReadAll(f) if err != nil { return "", err } return string(b), nil }
原则上:退出的具有多种功能的程序存在一些问题:
不明显的控制流:任何函数都可以退出程序,因此很难对控制流进行推理。
难以测试:退出程序的函数也将退出调用它的测试。这使得函数很难测试,并引入了跳过 go test
尚未运行的其他测试的风险。
跳过清理:当函数退出程序时,会跳过已经进入defer
队列里的函数调用。这增加了跳过重要清理任务的风险。
如果可能的话,你的main()
函数中最多一次 调用 os.Exit
或者log.Fatal
。如果有多个错误场景停止程序执行,请将该逻辑放在单独的函数下并从中返回错误。
这会缩短 main()
函数,并将所有关键业务逻辑放入一个单独的、可测试的函数中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package mainfunc main () { if err := run(); err != nil { log.Fatal(err) } } func run () error { args := os.Args[1 :] if len (args) != 1 { return errors.New("missing file" ) } name := args[0 ] f, err := os.Open(name) if err != nil { return err } defer f.Close() b, err := ioutil.ReadAll(f) if err != nil { return err } }
Interface (接口) 1.指向 interface 的指针 您几乎不需要指向接口类型的指针,您应该将接口作为值进行传递,在这样的传递过程中,实质上传递的底层数据仍然可以是指针。
接口实质上在底层用两个字段表示:
一个指向某些特定类型信息的指针,您可以将其视为”type”。
数据指针。如果存储的数据是指针,则直接存储。如果存储的数据是一个值,则存储指向该值的指针。
如果希望接口方法修改基础数据,则必须使用指针传递(将对象指针赋值给接口变量)。
1 2 3 4 5 6 7 8 9 10 11 12 type F interface { f() } type S1 struct {}func (s S1) f () {}type S2 struct {}func (s *S2) f () {}var f1 F = S1{} var f2 F = &S2{}
2.Interface 合理性验证 在编译时验证接口的符合性。这包括:
将实现特定接口的导出类型作为接口API 的一部分进行检查
实现同一接口的(导出和非导出)类型属于实现类型的集合
任何违反接口合理性检查的场景,都会终止编译,并通知给用户
补充: 上面3条是编译器对接口的检查机制,大体意思是错误使用接口会在编译期报错.所以可以利用这个机制让部分问题在编译期暴露。
weiyigeek.top-Bad&Good
例如,右边Good的代码块中,如果 *Handler
与 http.Handler
的接口不匹配, 那么语句 var _ http.Handler = (*Handler)(nil)
将无法编译通过.
Tips: 赋值的右边应该是断言类型的零值。 对于指针类型(如 *Handler
)、切片和映射,这是 nil
; 对于结构类型,这是空结构。
1 2 3 4 5 6 7 8 9 10 11 type LogHandler struct { h http.Handler log *zap.Logger } var _ http.Handler = LogHandler{} // 关键点 ,看LogHandler 接口是否实现 http.Handler 接口类型。 func (h LogHandler) ServeHTTP( w http.ResponseWriter, r *http.Request, ) { // ... }
3.接口与接收器 (receiver) 使用值接收器的方法既可以通过值调用,也可以通过指针调用。
带指针接收器的方法只能通过指针或 addressable values 调用.
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 type S struct { data string } func (s S) Read () string { return s.data } func (s *S) Write (str string ) { s.data = str } sVals := map [int ]S{1 : {"A" }} sVals[1 ].Read() sPtrs := map [int ]*S{1 : {"A" }} sPtrs[1 ].Read() sPtrs[1 ].Write("test" )
类似的,即使方法有了值接收器,也同样可以用指针接收器来满足接口.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 type F interface { f() } type S1 struct {}func (s S1) f () {}type S2 struct {}func (s *S2) f () {}s1Val := S1{} s1Ptr := &S1{} s2Val := S2{} s2Ptr := &S2{} var i Fi = s1Val i = s1Ptr i = s2Ptr
Effective Go 中有一段关于 pointers vs. values 的精彩讲解.
一个类型可以有值接收器方法集和指针接收器方法集,值接收器方法集
是指针接收器方法集的子集,反之不是就是像上面所说一个类型为指针接收器方法集时,就不能进行值传递。
值对象只可以使用值接收器方法集
指针对象可以使用 值接收器方法集 + 指针接收器方法集
接口的匹配(或者叫实现), 类型实现了接口的所有方法叫匹配;具体的讲,要么是类型的值方法集匹配接口,要么是指针方法集匹配接口
具体的匹配分两种:
值方法集和接口匹配: 给接口变量赋值的不管是值还是指针对象,都ok,因为都包含值方法集.
指针方法集和接口匹配: 只能将指针对象赋值给接口变量,因为只有指针方法集和接口匹配.
如果将值对象赋值给接口变量,会在编译期报错(会触发接口合理性检查机制) 为啥 i = s2Val 会报错,因为值方法集和接口不匹配,必须要指针方法集才匹配.
Slices 或 Maps (切片和字典) 初始化 Maps 描述: 对于空 map 请使用 make(..)
初始化, 并且 map 是通过编程方式填充的,这使得 map 初始化在表现上不同于声明,并且它还可以方便地在 make 后添加大小提示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var ( m1 = map [T1]T2{} m2 map [T1]T2 ) var ( m1 = map [T1]T2{} m2 map [T1]T2 )
Tips: 所以,在尽可能的情况下,请在初始化时提供 map 容量大小,详细请看 指定Map容量提示 。
另外,如果 map 包含固定的元素列表,则使用 map literals(map 初始化列表)
初始化映射。
1 2 3 4 5 6 7 8 9 10 11 12 // 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, }
基本准则是:在初始化时使用 map 初始化列表 来添加一组固定的元素。否则使用 make
(如果可以,请尽量指定 map 容量)。
在边界处拷贝 Slices 和 Maps 描述: slices 和 maps 包含了指向底层数据的指针,因此在需要复制它们时要特别注意。
接收 Slices 和 Maps
当 map 或 slice 作为函数参数传入时,如果您存储了对它们的引用,则用户可以对其进行修改。
1 2 3 4 5 6 7 8 9 10 func (d *Driver) SetTrips (trips []Trip) { d.trips = make ([]Trip, len (trips)) copy (d.trips, trips) } trips := ... d1.SetTrips(trips) trips[0 ] = ...
map 或 slice 的修改
同样请注意用户对暴露内部状态的 map 或 slice 的修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 type Stats struct { mu sync.Mutex counters map [string ]int } func (s *Stats) Snapshot () map [string ]int { s.mu.Lock() defer s.mu.Unlock() result := make (map [string ]int , len (s.counters)) for k, v := range s.counters { result[k] = v } return result } snapshot := stats.Snapshot()
追加时优先指定切片容量 描述: 在尽可能的情况下,在初始化要追加的切片时为make()
提供一个容量值,你可以在性能的那一章节中看见使用make可以减少切片在循环追加时对于内存分配的次数(增加执行效率
)。
1 2 3 4 5 6 7 for n := 0 ; n < b.N; n++ { data := make ([]int , 0 , size) for k := 0 ; k < size; k++{ data = append (data, k) } }
nil 是一个有效的 slice 描述: nil
是一个有效的长度为 0 的 slice,这意味着,您不应明确返回长度为零的切片而是应该返回nil
来代替。
1 2 3 4 if x == "" { return nil }
要检查切片是否为空,请始终使用len(s) == 0
,而非 nil
。
1 2 3 4 func isEmpty(s []string) bool { // return s == nil return len(s) == 0 }
零值切片(用var
声明的切片)可立即使用,无需调用make()
创建。
1 2 3 var nums []int if add1 {nums = append(nums, 1)} if add2 {nums = append(nums, 2)}
记住,虽然nil切片是有效的切片,但它不等于长度为0的切片(一个为nil,另一个不是
),并且在不同的情况下(例如序列化),这两个切片的处理方式可能不同。
Defer (资源释放) 描述: 在Go语言中,常常使用 defer 释放资源,诸如文件和锁。
Defer 的开销非常小,只有在您可以证明函数执行时间处于纳秒级的程度时,才应避免这样做,使用 defer 提升可读性是值得的,因为使用它们的成本微不足道。
尤其适用于那些不仅仅是简单内存访问的较大的方法,在这些方法中其他计算的资源消耗远超过 defer
。
1 2 3 4 5 6 7 8 9 10 11 p.Lock() defer p.Unlock()if p.count < 10 { return p.count } p.count++ return p.count
Sync (同步包) 描述: 零值 sync.Mutex
和 sync.RWMutex
是有效的, 所以指向 mutex
的指针基本是不必要的。
1 2 3 var mu sync.Mutexmu.Lock()
如果你使用结构体指针
,mutex 可以非指针形式作为结构体的组成字段,或者更好的方式是直接嵌入到结构体中。
如果是私有结构体类型
或是要实现 Mutex 接口的类型,我们可以使用嵌入 mutex 的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 type smap struct { sync.Mutex 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] }
ErrorHandling (错误处理) Go 中有多种声明错误(Error) 的选项:
返回错误时,请考虑以下因素以确定最佳选择:
这是一个不需要额外信息的简单错误吗?
如果是这样,errors.New
足够了。
客户需要检测并处理此错误吗?
如果是这样,则应使用自定义类型并实现该 Error()
方法。
您是否正在传播下游函数返回的错误?
如果是这样,请查看本文后面有关错误包装 section on error wrapping 部分的内容。
否则 fmt.Errorf
就可以了。
Tips: 错误处理原则是不能丢弃有返回err的调用,不能用_丢弃,必须全部处理尽早 return,采用独立的错误流处理。
1.错误声明 (Error Declare) 如果客户端需要检测错误,并且您已使用创建了一个简单的错误 errors.New
,请使用一个错误变量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var ErrCouldNotOpen = errors.New("could not open" )func Open () error { return ErrCouldNotOpen } if err := foo.Open(); err != nil { if errors.Is(err, foo.ErrCouldNotOpen) { } else { panic ("unknown error" ) } }
如果您有可能需要客户端检测的错误,并且想向其中添加更多信息(例如,它不是静态字符串),则应使用自定义类型,即Error()
方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 type errNotFound struct { file string } func (e errNotFound) Error () string { return fmt.Sprintf("file %q not found" , e.file) } func open (file string ) error { return errNotFound{file: file} } func use () { if err := open("testfile.txt" ); err != nil { if _, ok := err.(errNotFound); ok { } else { panic ("unknown error" ) } } }
直接导出自定义错误类型时要小心,因为它们已成为程序包公共 API 的一部分,最好公开匹配器功能以检查错误
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 type errNotFound struct { file string } func (e errNotFound) Error () string { return fmt.Sprintf("file %q not found" , e.file) } func IsNotFoundError (err error) bool { _, ok := err.(errNotFound) return ok } func Open (file string ) error { return errNotFound{file: file} } if err := foo.Open("foo" ); err != nil { if foo.IsNotFoundError(err) { } else { panic ("unknown error" ) } }
2.错误包装 (Error Wrapping) 一个(函数/方法)调用失败时,有三种主要的错误传播方式:
如果没有要添加的其他上下文,并且您想要维护原始错误类型,则返回原始错误。
添加上下文,使用"pkg/errors".Wrap
以便错误消息提供更多上下文 ,"pkg/errors".Cause
可用于提取原始错误。
如果调用者不需要检测或处理的特定错误情况,使用 fmt.Errorf
。
建议在可能的地方添加上下文,以使您获得诸如“调用服务 foo:连接被拒绝”之类的更有用的错误,而不是诸如“连接被拒绝”之类的模糊错误。
在将上下文添加到返回的错误时,请避免使用“failed to”之类的短语
以保持上下文简洁,这些短语会陈述明显的内容,并随着错误在堆栈中的渗透而逐渐堆积:
1 2 3 4 5 6 7 s, err := store.New() if err != nil { return fmt.Errorf( "new store: %v" , err) } x: y: new store: the error
但是,一旦将错误发送到另一个系统,就应该明确消息是错误消息(例如使用err
标记,或在日志中以”Failed”为前缀)。
另请参见 Don’t just check errors, handle them gracefully . 不要只是检查错误,要优雅地处理错误
3.处理类型断言失败 描述: type assertion 的单个返回值形式针对不正确的类型将产生 panic。因此请始终使用“comma ok”
的惯用法。
1 2 3 4 t, ok := i.(string ) if !ok { }
4.避免使用 panic 描述: 在生产环境中运行的代码必须避免出现 panic。
panic 是 cascading failures 级联失败的主要根源 ,如果发生错误,该函数必须返回错误,并允许调用方决定如何处理它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func run (args []string ) error { if len (args) == 0 { return errors.New("an argument is required" ) } return nil } func main () { if err := run(os.Args[1 :]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1 ) } }
panic/recover
不是错误处理策略,仅当发生不可恢复的事情(例如:nil 引用)时,程序才必须 panic。
程序初始化是一个例外:程序启动时应使程序中止的不良情况可能会引起 panic。
1 var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))
即使在测试代码中,也优先使用t.Fatal
或者t.FailNow
而不是 panic 来确保失败被标记。
1 2 3 4 5 6 f, err := ioutil.TempFile("" , "test" ) if err != nil { t.Fatal("failed to set up test" ) }
5.go.uber.org/atomic 使用 sync/atomic 包的原子操作对原始类型 (int32
, int64
等)进行操作,因为很容易忘记使用原子操作来读取或修改变量。
go.uber.org/atomic 通过隐藏基础类型为这些操作增加了类型安全性。此外,它包括一个方便的atomic.Bool
类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type foo struct { running atomic.Bool } func (f *foo) start() { if f.running.Swap(true) { // already running… return } // start the Foo } func (f *foo) isRunning() bool { return f.running.Load() }
Channel (通道) 描述: channel 通常 size 应为 1 或是无缓冲的。
默认情况下,channel 是无缓冲的,其 size 为零,任何其他尺寸都必须经过严格的审查。我们需要考虑如何确定大小,考虑是什么阻止了 channel 在高负载下和阻塞写时的写入,以及当这种情况发生时系统逻辑有哪些变化。
(翻译解释:按照原文意思是需要界定通道边界,竞态条件,以及逻辑上下文梳理)
1 2 3 4 5 6 c := make (chan int , 1 ) c := make (chan int )
iota (枚举) 描述: 在 Go 中引入枚举的标准方法是声明一个自定义类型和一个使用了 iota 的 const 组。由于变量的默认值为 0,因此通常应以非零值开头枚举。
1 2 3 4 5 6 7 8 type Operation int const ( Add Operation = iota + 1 Subtract Multiply )
在某些情况下,使用零值是有意义的(枚举从零开始),例如,当零值是理想的默认行为时。
1 2 3 4 5 6 7 8 type LogOutput int const ( LogToStdout LogOutput = iota LogToFile LogToRemote )
Unit (单元测试) 单元测试必须导入testing包,已经编程文件必须以_test.go
结尾,并且测试函数命名也有要求,单元测试必须以Test开头后接测试函数名(注意首字母大写)
,而基准(性能)测试必须以Benchmark开头后接函数名
, 而示例函数必须以Example开头后接函数名称
.
1 2 3 4 5 6 7 8 9 10 11 // 单元测试 func TestSplitStr(t *testing.T){...code...} func Test_SplitStr(t *testing.T){...code...} // 基准测试 func TestSplitStr(b *testing.B){...code...} func Test_SplitStr(b *testing.B){...code...} // 示例函数 func ExampleSplitStr(){...code...} func Example_SplitStr(){...code...}
组测试
当测试逻辑是重复的时候,通过 subtests 使用 table 驱动的方式编写 case 代码看上去会更简洁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 tests := []struct { give string wantHost string wantPort string }{ { give: "192.0.2.0:8000" , wantHost: "192.0.2.0" , wantPort: "8000" , }, { give: "192.0.2.0:http" , wantHost: "192.0.2.0" , wantPort: "http" , }, { give: ":8000" , wantHost: "" , wantPort: "8000" , }, { give: "1:8" , wantHost: "1" , wantPort: "8" , }, } for _, tt := range tests { t.Run(tt.give, func (t *testing.T) { host, port, err := net.SplitHostPort(tt.give) require.NoError(t, err) assert.Equal(t, tt.wantHost, host) assert.Equal(t, tt.wantPort, port) }) }
很明显,使用 test table 的方式在代码逻辑扩展的时候,比如新增 test case,都会显得更加的清晰。
我们遵循这样的约定:将结构体切片称为tests
, 每个测试用例称为tt
。
此外,我们鼓励使用give
和want
前缀说明每个测试用例的输入和输出值。
描述: 性能方面的特定准则只适用于高频场景。
字符串类型转换 优先使用 strconv 而不是 fmt,将原语转换为字符串或从字符串转换时,strconv速度比fmt快。
1 2 3 4 5 for i := 0 ; i < b.N; i++ { s := strconv.Itoa(rand.Int()) }
避免字符串到字节的转换 描述: 不要反复从固定字符串创建字节 slice, 相反请执行一次转换并捕获结果。
1 2 3 4 5 data := []byte("Hello world") // 推荐 for i := 0; i < b.N; i++ { w.Write(data) // 推荐 // w.Write([]byte("Hello world")) // 不推荐类型转换 }
指定容器容量 描述: 尽可能指定容器容量,以便为容器预先分配内存,这将在添加元素时最小化后续分配(通过复制和调整容器大小)。
指定Map容量:
在尽可能的情况下,在使用 make() 初始化的时候提供容量信息make(map[T1]T2, hint)
,向make()
提供容量提示会在初始化时尝试调整map的大小,这将减少在将元素添加到map时为map重新分配内存。
注意,与slices不同。map capacity提示并不保证完全的抢占式分配,而是用于估计所需的hashmap bucket的数量。 因此,在将元素添加到map时,甚至在指定map容量时,仍可能发生分配。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 m := make (map [string ]os.FileInfo) files, _ := ioutil.ReadDir("./files" ) for _, f := range files { m[f.Name()] = f } files, _ := ioutil.ReadDir("./files" ) m := make (map [string ]os.FileInfo, len (files)) for _, f := range files { m[f.Name()] = f }
指定切片容量:
描述: 在尽可能的情况下,在使用make()
初始化切片时提供容量信息,特别是在追加切片时 make([]T, length, capacity)
。
与maps不同,slice capacity
不是一个提示:编译器将为提供给make()
的slice的容量分配足够的内存, 这意味着后续的append()
操作将导致零分配(直到slice的长度与容量匹配,在此之后,任何append都可能调整大小以容纳其他元素)。
1 2 3 4 5 6 for n := 0 ; n < b.N; n++ { data := make ([]int , 0 , size) for k := 0 ; k < size; k++{ data = append (data, k) } }
Time (时间处理) Go语言中为我们处理时间提供一个强大的内置包time
,它有助于以更安全、更准确的方式处理这些不正确的假设。
关于时间的错误假设通常包括以下几点:一天有 24 小时、一小时有 60 分钟、一周有七天、一年 365 天
例如,1 表示在一个时间点上加上 24 小时并不总是产生一个新的日历日。
Tips : Go 语言的格式化字符串为”2006-01-02 15:04:06”
使用 time.Time
表达瞬时时间 描述: 在处理时间的瞬间时使用 time.Time
,在比较、添加或减去时间时使用 time.Time
中的方法。
1 2 3 4 5 // 推荐方法 , 内置的时间比较方法 相比于比较运算符更精确。 func isActive(now, start, stop time.Time) bool { // return start <= now && now < stop // 不推荐 return (start.Before(now) || start.Equal(now)) && now.Before(stop) }
使用 time.Duration
表达时间段 先看示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func poll (delay int ) { for { time.Sleep(time.Duration(delay) * time.Millisecond) } } poll(10 ) func poll (delay time.Duration) { for { time.Sleep(delay) } } poll(10 *time.Second)
回到第一个例子,在一个时间瞬间加上 24 小时,我们用于添加时间的方法取决于意图。
如果我们想要下一个日历日(当前天的下一天)的同一个时间点,我们应该使用 Time.AddDate
。但是,如果我们想保证某一时刻比前一时刻晚 24 小时,我们应该使用 Time.Add
。
1 2 newDay := t.AddDate(0 /* years */, 0 /* months */, 1 /* days */) maybeNewDay := t.Add(24 * time.Hour)
尽可能在与外部系统的交互中使用 time.Duration
和 time.Time
例如 :
当不能在这些交互中使用 time.Duration
时,请使用 int
或 float64
,并在字段名称中包含单位。
例如,由于 encoding/json
不支持 time.Duration
,因此该单位包含在字段的名称中。
1 2 3 4 5 // {"intervalMillis": 2000} type Config struct { IntervalMillis int `json:"intervalMillis"` }
当在这些交互中不能使用 time.Time
时,除非达成一致,否则使用 string
和 RFC 3339 中定义的格式时间戳。
默认情况下,Time.UnmarshalText
使用此格式,并可通过 time.RFC3339
在 Time.Format
和 time.Parse
中使用。
尽管这在实践中并不成问题,但请记住,"time"
包不支持解析闰秒时间戳(Issue-8728 ),也不在计算中考虑闰秒(Issue-15190 )。
如果您比较两个时间瞬间,则差异将不包括这两个瞬间之间可能发生的闰秒。
String (字符串处理) 描述: 如果你在函数外声明Printf
-style 函数的格式字符串,请将其设置为const
常量,此有助于go vet
对格式字符串执行静态分析。
1 2 3 4 5 6 7 msg := "unexpected values %v, %v\n" fmt.Printf(msg, 1 , 2 ) const msg = "unexpected values %v, %v\n" fmt.Printf(msg, 1 , 2 )
命名 Printf 样式的函数
描述: 声明Printf
-style 函数时,请确保go vet
可以检测到它并检查格式字符串。
如果不能使用预定义的名称,请以 f 结束选择的名称:Wrapf
,而不是Wrap
。go vet
可以要求检查特定的 Printf 样式名称,但名称必须以f
结尾。
1 2 $ go vet -printfuncs=wrapf,statusf // “go.vetFlags”: [“printfuncs=wrapf”]
0x03 规范辅助工具 Linting 相关工具 比任何 “blessed” linter 集更重要的是,lint在一个代码库中始终保持一致。
我们建议至少使用以下linters,因为我认为它们有助于发现最常见的问题,并在不需要规定的情况下为代码质量建立一个高标准:
Lint Runners
描述: 我们推荐 golangci-lint 作为go-to lint的运行程序,这主要是因为它在较大的代码库中的性能以及能够同时配置和使用许多规范。这个repo有一个示例配置文件.golangci.yml 和推荐的linter设置。
golangci-lint 有various-linters 可供使用。建议将上述linters作为基本set,我们鼓励团队添加对他们的项目有意义的任何附加linters。