目录
- 统一规范篇
- 命名篇
- 开发篇
- 优化篇
统一规范篇
本篇主要描述了公司内部同事都必须遵守的一些开发规矩,如统一开发空间,既使用统一的开发工具来保证代码最后的格式的统一,开发中对文件和代码长度的控制,必须经过go语言自带的检测机制等。
1.1 合理规划目录
【原则1.1】合理规划目录,一个目录中只包含一个包(实现一个模块的功能),如果模块功能复杂考虑拆分子模块,或者拆分目录。
说明:在Go中对于模块的划分是基于package这个概念,可以在一个目录中可以实现多个package,但是并不建议这样的实现方式。主要的缺点是模块之间的关系不清晰,另外不利于模块功能扩展。
错误示例:
project
│ config.go
│ controller.go
│ filter.go
│ flash.go
│ log.go
│ memzipfile.go
│ mime.go
│ namespace.go
│ parser.go
│ router.go
│ staticfile.go
│ template.go
│ templatefunc.go
│ tree.go
│ util.go
| validation.go
| validators.go
推荐做法:
project
├─cache
│ │ cache.go
│ │ conv.go
│ │
│ └─redis
│ redis.go
├─config
│ │ config.go
│ │ fake.go
│ │ ini.go
│ └─yaml
│ yaml.go
├─logs
│ conn.go
│ console.go
│ file.go
│ log.go
│ smtp.go
└─validation
util.go
validation.go
validators.go
1.2 GOPATH设置
【建议1.2】使用单一的 GOPATH
虽说Go语言支持拥有多个 GOPATH,但多个GOPATH的情况并不具有弹性。GOPATH本身就是高度自我完备的(通过导入路径)。有多个 GOPATH 会导致某些副作用,例如可能使用了给定的库的不同的版本。你可能在某个地方升级了它,但是其他地方却没有升级。而且,我还没遇到过任何一个需要使用多个 GOPATH 的情况。所以只使用单一的 GOPATH,这会提升你 Go 的开发进度。
许多人不同意这一观点,接下来我会做一些澄清。像 etcd 或 camlistore 这样的大项目使用了像 godep 这样的工具,将所有依赖保存到某个目录中。也就是说,这些项目自身有一个单一的 GOPATH。它们只能在这个目录里找到对应的版本。除非你的项目很大并且极为重要,否则不要为每个项目使用不同的 GOPAHT。如果你认为项目需要一个自己的 GOPATH 目录,那么就创建它,否则不要尝试使用多个 GOPATH。它只会拖慢你的进度。
所有项目共用一个workspace,如下图所示:
workspace/
├── bin
├── pkg
│ └── linux_amd64
│
└── src
├── project1
│
└── project2
│
└── project3
│
└── …
优点: 方便发布到github.com, 让第三方通过go get等工具获取。
内部项目,建议采用第一种工程结构。公开项目、提供给第三方集成的项目采用第二种项目结构。
1.3 import 规范
import路径是一个唯一标示的字符串
import在多行的情况下,goimports会自动帮你格式化,但是我们这里还是规范一下import的一些规范,如果你在一个文件里面引入了一个package,还是建议采用如下格式:
import (
"fmt"
)
如果你的包引入了三种类型的包,标准库包,程序内部包,第三方包,建议采用如下方式进行组织你的包:
import (
"encoding/json"
"strings"
"myproject/models"
"myproject/controller"
"myproject/utils"
"github.com/astaxie/beego"
"github.com/go-sql-driver/mysql"
)
有顺序的引入包,不同的类型采用空格分离,第一种实标准库,第二是项目包,第三是第三方包。
【规则1.3.1】在非测试文件(*_test.go)中,禁止使用 . 来简化导入包的对象调用。
错误示例:
// 这是不好的导入
import . " pubcode/api/broker"
这种写法不利于阅读,因而不提倡。
【规则1.3.2】禁止使用相对路径导入(./subpackage),所有导入路径必须符合 go get 标准。
错误示例:
// 这是不好的导入
import "../net"
正确做法:
// 这是正确的做法
import "github.com/repo/proj/src/net"
【建议1.3.3】建议使用goimports工具或者IDE工具来管理多行import
go默认已经有了gofmt工具,但是我们强烈建议使用goimport工具,这个在gofmt的基础上增加了自动删除和引入包.
go get golang.org/x/tools/cmd/goimports
不同的编辑器有不同的配置, sublime的配置教程:http://michaelwhatcott.com/gosublime-goimports/
LiteIDE和GoLand默认已经支持了goimports,如果你的不支持请点击属性配置->golangfmt->勾选goimports
保存之前自动fmt你的代码。
好处:import在多行的情况下,goimports工具会自动帮你格式化,自动删除和引入包。很多IDE工具也可以自动检查并纠正import路径
1.4 代码风格
Go语言对代码风格作了很多强制的要求,并提供了工具gofmt, golint, go tool vet等工具检查。
【规则1.4.1】提交代码时,必须使用gofmt对代码进行格式化。
大部分的格式问题可以通过 gofmt 来解决,gofmt 自动格式化代码,保证所有的 go 代码与官方推荐的格式保持一致,所有格式有关问题,都以gofmt的结果为准。所以,建议在提交代码库之前先运行一下这个命令。
gofmt(也可以用go fmt,其操作于程序包的级别,而不是源文件级别),读入Go的源代码,然后输出按照标准风格缩进和垂直对齐的源码,并且保留了根据需要进行重新格式化的注释。如果你想知道如何处理某种新的布局情况,可以运行gofmt;如果结果看起来不正确,则需要重新组织你的程序,不要把问题绕过去。标准程序包中的所有Go代码,都已经使用gofmt进行了格式化。
不需要花费时间对结构体中每个域的注释进行排列,如下面的代码,
type T struct {
name string // name of the object
value int // its value
}
gofmt将会按列进行排列:
type T struct {
name string // name of the object
value int // its value
}
【规则1.4.2】提交代码时,必须使用golint对代码进行检查。
golint 会检测的方面:
- 变量名规范
- 变量的声明,像var str string = "test",会有警告,应该var str = "test"
- 大小写问题,大写导出包的要有注释
- x += 1 应该 x++
等等
详细可以看官方库示例,https://github.com/golang/lint/tree/master/testdata
想速成的可以看Golang lint简易使用方法自行学习使用
【建议1.4.3】提交代码前,必须使用go vet对代码进行检查。
如果说golint是检查我们的代码规范的话,那么vet工具则是可以帮我们静态分析我们的源码存在的各种问题,例如多余的代码,提前return的逻辑,struct的tag是否符合标准等。
go get golang.org/x/tools/cmd/vet
使用如下:
go vet .
1.5 大小约定
【建议1.5.1】单个文件长度不超过500行。
对开源引入代码可以降低约束,新增代码必须遵循。
【建议1.5.2】单个函数长度不超过50行。
函数两个要求:单一职责、要短小
【规则1.5.3】单个函数圈复杂度最好不要超过10,禁止超过15。
说明:圈复杂度越高,代码越复杂,就越难以测试和维护,同时也说明函数职责不单一。
【规则1.5.4】单行语句不能过长,如不能拆分需要分行写。一行最多120个字符。
换行时有如下建议:
换行时要增加一级缩进,使代码可读性更好;
低优先级操作符处划分新行;换行时操作符应保留在行尾;
换行时建议一个完整的语句放在一行,不要根据字符数断行
示例:
if ((tempFlag == TestFlag) &&
(((counterVar - constTestBegin) % constTestModules) >= constTestThreshold)) {
// process code
}
【建议1.5.5】函数中缩进嵌套必须小于等于3层。
举例,禁止出现以下这种锯齿形的函数:
func testUpdateOpts PushUpdateOptions) (err error) {
isNewRef := opts.OldCommitID == git.EMPTY_SHA
isDelRef := opts.NewCommitID == git.EMPTY_SHA
if isNewRef && isDelRef {
if isDelRef {
repo, err := GetRepositoryByName(owner.ID, opts.RepoName)
if err != nil {
if strings.HasPrefix(opts.RefFullName, git.TAG_PREFIX) {
if err := CommitRepoAction(CommitRepoActionOptions{
PusherName: opts.PusherName,
RepoOwnerID: owner.ID,
RepoName: repo.Name,
RefFullName: opts.RefFullName,
OldCommitID: opts.OldCommitID,
NewCommitID: opts.NewCommitID,
Commits: &PushCommits{},
}); err != nil {
return fmt.Errorf("CommitRepoAction (tag): %v", err)
}
return nil
}
}
else {
owner, err := GetUserByName(opts.RepoUserName)
if err != nil {
return fmt.Errorf("GetUserByName: %v", err)
}
return nil
}
}
}
// other code
}
提示:如果发现锯齿状函数,应通过尽早通过return等方法重构。
【原则1.5.6】保持函数内部实现的组织粒度是相近的。
举例,不应该出现如下函数:
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()
}
参考链接
https://studygolang.com/articles/2059
https://studygolang.com/articles/12033
https://blog.csdn.net/shuanger_/article/details/48241767
https://blog.csdn.net/tanzhe2017/article/list
命名篇
本篇以开发时从上往下的顺序既:开发前约定的基本命名规范、包、常量、变量、结构体、参数、返回值的顺序讲解了开发中各个环节的命名规范。
2.1 基本命令规范
【规则2.1.1】需要注释来补充的命名就不算是好命名。
说明:命名应该做到让人见名知意,好的命名可以让人节省关注注释的时间。
【规则2.1.2】使用可搜索的名称
说明:单字母名称和数字常量很难从一大堆文字中搜索出来。单字母名称仅适用于短方法中的本地变量,名称长短应与其作用域相对应。若变量或常量可能在代码中多处使用,则应赋其以便于搜索的名称。
【规则2.1.3】做有意义的区分
说明:要区分名称,就要以读者能鉴别不同之处的方式来区分
比如说Product 和 ProductInfo 和 ProductData 没有区别,NameString 和 Name 没有区别。
错误示例:
type Reader interface {
Read(p []byte) (n int, err error)
}
// 多个函数接口
type WriteFlusher interface {
Write([]byte) (int, error)
Flush() error
}
2.2 项目目录名
【规则2.2.1】目录名必须为全小写单词,允许加中划线‘-’组合方式,但是头尾不能为中划线。
例如:
go-sql-driver
hsa-microservice
service-mgr
【建议2.2.2】虽然允许出现中划线,但是尽量避免或少加中划线。
2.3 包名
【原则2.3.1】取名尽量采取有意义的包名,简单和可阅读。
【规则2.3.2】包名必须全部为小写单词,无下划线,越短越好。尽量不要与标准库重名。
错误示例:
import (
"MyUtil" //包名大写
"suffix_array" //有下划线
"io/util suffixarray" //不仅长,还是个带空格的包名
"io/ioutil" //与标准库重名
)
说明:包名在被导入后,会以 package.Func()方式使用,任何人使用你的包都得敲一遍该包名,因为包名也是类型和函数的一部分
例如buf := new(bytes.Buffer)
就不要取名为 bytes.BytesBuffer
,这样过于累赘。
【规则2.3.3】禁止通过中划线连接多个单词的方式来命名包名。
package go-oci8 //编译错误
【建议2.3.4】包名尽量与所在目录名一致,引用时比较方便。
说明:这是因为在import导入的包是按目录名来命名的,如果不一致,代码阅读者就很困惑。
2.4 文件名
和其它语言一样,名字在Go中是非常重要的。它们甚至还具有语义的效果:一个名字在程序包之外的可见性是由它的首字符是否为大写来确定的。因此,值得花费一些时间来讨论Go程序中的命名约定。
【规则2.4.1】文件名必须为小写单词,允许加下划线‘_’组合方式,但是头尾不能为下划线。
例如: port_allocator.go
【建议2.4.2】虽然允许出现下划线,但是尽量避免。
说明:如果采用下划线的方式,注意避免跟下面保留特定用法的后缀冲突:
1)测试文件:_test.go
2)系统相关的文件:
_386.go、_amd64.go、_arm.go、_arm64.go、_android.go、_darwin.go、_dragonfly.go、_freebsd.go、_linux.go、_nacl.go、_netbsd.go、_openbsd.go、_plan9.go、_solaris.go、_windows.go、_android_386.go、_android_amd64.go、_android_arm.go、_android_arm64.go、_darwin_386.go、_darwin_amd64.go、_darwin_arm.go、_darwin_arm64.go、_dragonfly_amd64.go、_freebsd_386.go、_freebsd_amd64.go、_freebsd_arm.go、_linux_386.go、_linux_amd64.go、_linux_arm.go、_linux_arm64.go、_linux_mips64.go、_linux_mips64le.go、_linux_ppc64.go、_linux_ppc64le.go、_linux_s390x.go、_nacl_386.go、_nacl_amd64p32.go、_nacl_arm.go、_netbsd_386.go、_netbsd_amd64.go、_netbsd_arm.go、_openbsd_386.go、_openbsd_amd64.go、_openbsd_arm.go、_plan9_386.go、_plan9_amd64.go、_plan9_arm.go、_solaris_amd64.go、_windows_386.go
_windows_amd64.go
【建议2.4.3】文件名以功能为指引,名字中不需再出现模块名或者组件名。
说明:因为Go包的导入是与路径有关的,本身已经隐含了模块/组件信息。
2.5 常量
【规则2.5.1】常量&枚举名采用大小写混排的驼峰模式(Golang官方要求),不允许出现下划线
示例:
const (
CategoryBooks = iota // 0
CategoryHealth // 1
CategoryClothing // 2
)
【建议2.5.2】按照功能来区分,而不是将所有类型都分在一组,并建议将公共常量置于私有常量之前
示例:
const (
KindPage = "page"
// The rest are node types; home page, sections etc.
KindHome = "home"
KindSection = "section"
KindTaxonomy = "taxonomy"
KindTaxonomyTerm = "taxonomyTerm"
// Temporary state.
kindUnknown = "unknown"
// The following are (currently) temporary nodes,
// i.e. nodes we create just to render in isolation.
kindRSS = "RSS"
kindSitemap = "sitemap"
kindRobotsTXT = "robotsTXT"
kind404 = "404"
)
【规则2.2.3】如果是枚举类型的常量,需要先创建相应类型
示例:
type tstCompareType int
const (
tstEq tstCompareType = iota
tstNe
tstGt
tstGe
tstLt
tstLe
)
【建议2.5.4】如果模块的功能较为复杂、常量名称容易混淆的情况下,为了更好地区分枚举类型,可以使用完整的前缀
示例:
type PullRequestStatus int
const (
PullRequestStatusConflict PullRequestStatus = iota
PullRequestStatusChecking
PullRequestStatusMergeable
)
2.6 变量
变量申明
【规则2.6.1】变量命名基本上遵循相应的英文表达或简写,在相对简单的环境(对象数量少、针对性强)中,可以将一些名称由完整单词简写为单个字母
例如:
- user 可以简写为 u
- userID 可以简写 uid
- 若变量类型为 bool 类型,则名称应以 Has, Is, Can 或 Allow 开头:
var isExist bool
var hasConflict bool
var canManage bool
var allowGitHook bool
只有从其他标准移植过来的常量才和原来保持一致,比如:
- 自定义的 http.StatusOK
- 移植过来的 tls.TLS_RSA_WITH_AES_128_CBC_SHA
变量命名惯例
【规则2.6.2】变量名称一般遵循驼峰法,并且不允许出现下划线,当遇到特有名词时,需要遵循以下规则:
- 如果变量为私有,且特有名词为首个单词,则使用小写,如:apiClient
- 其它情况都应当使用该名词原有的写法,如 APIClient、repoID、UserID
错误示例:UrlArray,应该写成 urlArray 或者 URLArray
下面列举了一些常见的特有名词:
"API","ASCII","CPU","CSS","DNS","EOF",GUID","HTML","HTTP","HTTPS","ID","IP","JSON","LHS","QPS","RAM","RHS"
"RPC", "SLA","SMTP","SSH","TLS","TTL","UI","UID","UUID","URI","URL", "UTF8","VM","XML","XSRF","XSS"
【规则2.6.3】不要使用_来命名变量名,多个变量申明放在一起
正确示例:
var (
Found bool
count int
)
【规则2.6.4】在函数外部申明必须使用var,不要采用:=,容易踩到变量的作用域的问题。
全局变量名
【规则2.6.5】全局变量必须为大小写混排的驼峰模式,不允许出现下划线。首字母根据作为范围确定大小写。
例如:
var Global int //包外
var global int //包内
【建议2.6.6】尽量避免跨package使用全局变量,尽量减少全局变量的使用。
局部变量名
【规则2.6.7】局部变量名必须为大小写混排,且首字母小写,不能有下划线。
例如:
result, err := MakeRegexpArray(str)
循环变量
【建议2.6.8】for循环变量可以使用单字母。
2.7 结构体(struct)
【规则2.7.1】struct申明和初始化格式采用多行
定义如下:
type User struct{
Username string
Email string
}
初始化如下:
u := User{
Username: "astaxie",
Email: "astaxie@gmail.com",
}
【规则2.7.2】结构体名必须为大小写混排的驼峰模式,不允许出现下划线,可被包外部引用则首字母大写;如仅包内使用,则首字母小写。
例如:
type ServicePlan struct
type internalBroker struct
【建议2.7.3】结构名建议采用名词、动名词为好。
结构体名应该是名词或名词短语,如Custome、WikiPage、Account、AddressParser,避免使用 Manager、Processor、Data、Info、这样的类名,类名不应当是动词。
2.8 接口名
接口命名规则:单个函数的接口名以”er”作为后缀,
【规则2.8.1】接口名必须为大小写混排,支持包外引用则首字母大写,仅包内使用则首字母小写。不能有下划线,整体必须为名词。
【建议2.8.2】单个函数的接口名以”er”作为后缀。
单个函数的接口名以”er”作为后缀,如Reader,Writer。接口的实现则去掉“er”。除非有更合适的单词。
例如:
type Reader interface {...}
2.9 函数和方法名
函数
【规则2.9.1】函数名必须为大小写混排的驼峰模式
函数名必须为大小写混排的驼峰模式,名字可以长但是得把功能,必要的参数描述清楚,不允许出现下划线。
示例:
func MakeRegexpArrayOrDie // 暴露给包外部函数
func matchesRegexp // 包内部函数
【建议2.9.2】函数名力求精简准确,并采用用动词或动词短
如 postPayment、deletePage、save。并依 Javabean 标准加上 get、set、is前缀。
例如:xxx + With + 需要的参数名 + And + 需要的参数名 + …..
方法
【规则2.9.3】方法接收名必须为大小写混排,首字母小写。方法接收者命名要能够体现接收者对象。
【建议2.9.4】接收者名通常1个或者2个字母就够,最长不能超过4个字母。
【建议2.9.5】接收者名不要使用me,this 或者 self 这种泛指的名字。
例如:
func (c *Controller) Run(stopCh <-chan struct{})
参考:https://github.com/golang/go/wiki/CodeReviewComments#receiver-names
【建议2.9.6】定义方法时,如果方法内不会直接引用接收者,则省略掉接收者名。
举例:
func (T) sayHi() {
// do things without T
}
func (*T) sayHello() {
// do things without *T
}
2.10 参数名
【规则2.10】参数名必须为大小写混排,且首字母小写,不能有下划线。
例如:
func MakeRegexpArray(str string)
2.11 返回值
【规则2.11.1】返回值如果是命名的,则必须大小写混排,首字母小写。
【建议2.11.2】 函数的返回值应避免使用命名的参数。
举例:
func (n *Node) Bad() (node *Node, err error)
func (n *Node) Good() (*Node, error)
因为如果使用命名变量很容易导致临时变量覆盖而引起隐藏的bug。
例外情况:多个返回值类型相同的情况下,使用命名返回值来区分不同的返回参数。
说明:命名返回值使代码更清晰,同时更加容易读懂。
举例:
func getName()(firstName, lastName, nickName string){
firstName = "May"
lastName = "Chen"
nickName = "Babe"
return
}
参考:
https://github.com/golang/go/wiki/CodeReviewComments#named-result-parameters
https://golang.org/doc/effective_go.html#named-results
开发篇
说明:本篇主要是讲解开发中各个环节的开发规范和对一些代码的优化写法。在本文中有一些特别标黄的建议,我真的建议你好好看看那些代码,因为那可能对你提高代码开发会很有帮助。
3.1 包
第三方包管理
【建议3.1.1】项目仓库中包含全量的代码
说明:将依赖源码都放到当前工程的vendor目录下,将全量的代码保存到项目仓库中,这样做有利于避免受第三方变动的影响。
【建议3.1.2】建议采用 Glide 来管理第三方包
第三方包应该尽量获取release版本,而非master分支的版本。master上的版本通常是正在开发的非稳定版本。
3.2 魔鬼数字
【规则3.2】代码中禁止使用魔鬼数字。
说明:直接使用数字,造成代码难以理解,也难以维护。应采用有意义的静态变量或枚举来代替。
例外情况:有些特殊情况下,如循环或比较时采用数字0,-1,1,这些情况可采用数字。
3.3 常量 & 枚举
【==建议3.3.1==】 为整数常量添加 String() 方法
如果你利用 iota 来使用自定义的整数枚举类型,务必要为其添加 String() 方法。例如,像这样:
type State int
const
(
Running State = iota
Stopped
Rebooting
Terminated
)
如果你创建了这个类型的一个变量,然后输出,会得到一个整数(http://play.golang.org/p/V5VVFB05HB):
func main() {
state := Running
// print: "state 0"
fmt.Println("state ", state)
}
除非你回顾常量定义,否则这里的0看起来毫无意义。只需要为State类型添加String()方法就可以修复这个问题(http://play.golang.org/p/ewMKl6K302):
func (s State) String() string {
switchs {
caseRunning:
return"Running"
caseStopped:
return"Stopped"
caseRebooting:
return"Rebooting"
caseTerminated:
return"Terminated"
default:
return"Unknown"
}
}
新的输出是:state: Running。显然现在看起来可读性好了很多。在你调试程序的时候,这会带来更多的便利。同时还可以在实现 MarshalJSON()、UnmarshalJSON() 这类方法的时候使用同样的手段。
【==建议3.3.2==】让 iota 从 a +1 开始增量
在前面的例子中同时也产生了一个我已经遇到过许多次的 bug。假设你有一个新的结构体,有一个State字段:
type T struct{
Name string
Port int
State State
}
现在如果基于 T 创建一个新的变量,然后输出,你会得到奇怪的结果(http://play.golang.org/p/LPG2RF3y39):
func main() {
t := T{Name:"example", Port: 6666}
// prints: "t {Name:example Port:6666 State:Running}"
fmt.Printf("t %+v\n", t)
}
看到 bug 了吗?State
字段没有初始化,Go 默认使用对应类型的零值进行填充。由于State
是一个整数,零值也就是0
,但在我们的例子中它表示Running
。
那么如何知道 State 被初始化了?还是它真得是在Running
模式?没有办法区分它们,那么这就会产生未知的、不可预测的 bug。不过,修复这个很容易,只要让 iota 从 +1 开始(http://play.golang.org/p/VyAq-3OItv):
const
(
Running State = iota + 1
Stopped
Rebooting
Terminated
)
现在t变量将默认输出Unknown,不是吗? :) :
func main() {
t := T{Name:"example", Port: 6666}
// 输出: "t {Name:example Port:6666 State:Unknown}"
fmt.Printf("t %+v\n", t)
}
不过让 iota 从零值开始也是一种解决办法。例如,你可以引入一个新的状态叫做Unknown
,将其修改为:
const
(
Unknown State = iota
Running
Stopped
Rebooting
Terminated
)
3.4 结构体
【规则3.4.1】对于要使用json转换的结构体代码,变量名必须为大写,否则你只会得到一个为空的对象
例如:
BaiduNewsItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
Author string `xml:"author"`
}
【==建议3.4.2==】 在初始化结构体时使用带有标签的语法
这是一个无标签语法的例子:
type T struct{
Foo string
Bar int
}
func main() {
t := T{"example", 123}// 无标签语法
fmt.Printf("t %+v\n", t)
}
那么如果你添加一个新的字段到T结构体,代码会编译失败:
type T struct{
Foo string
Bar int
Qux string
}
func main() {
t := T{"example", 123}// 无法编译
fmt.Printf("t %+v\n", t)
}
如果使用了标签语法,Go的兼容性规则(http://golang.org/doc/go1compat)会处理代码。例如在向net包的类型添加叫做Zone的字段,参见:http://golang.org/doc/go1.1#library。回到我们的例子,使用标签语法:
type T struct{
Foo string
Bar int
Qux string
}
func main() {
t := T{Foo:"example", Bar: 123}
fmt.Printf("t %+v\n", t)
}
这个编译起来没问题,而且弹性也好。不论你如何添加其他字段到T结构体。你的代码总是能编译,并且在以后的 Go 的版本也可以保证这一点。只要在代码集中执行go vet,就可以发现所有的无标签的语法。
【==建议3.4.3==】将结构体的初始化拆分到多行
如果有两个以上的字段,那么就用多行。它会让你的代码更加容易阅读,也就是说不要:
T{Foo: "example", Bar:someLongVariable, Qux:anotherLongVariable, B: forgetToAddThisToo}
而是:
T{
Foo: "example",
Bar: someLongVariable,
Qux: anotherLongVariable,
B: forgetToAddThisToo,
}
这有许多好处,首先它容易阅读,其次它使得允许或屏蔽字段初始化变得容易(只要注释或删除它们),最后添加其他字段也更容易(只要添加一行)。
3.5 运算符
【规则3.5】运算符前后、逗号后面、if后面等需有单空格隔开。
if err != nil {…}
c := a + b
return {}, err
例外情况:
go fmt
认为应该删除空格的场景。例如,在传参时,字符串拼接的”+”号。
3.6 函数
【原则3.6.1】保持函数内部实现的组织粒度是相近的。
举例,不应该出现如下函数:
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()
}
【==建议3.6.2==】 返回函数调用
我已经看过很多代码例如(http://play.golang.org/p/8Rz1EJwFTZ):
func bar() (string, error) {
v, err := foo()
iferr != nil {
return"", err
}
returnv, nil
}
然而,你只需要:
func bar() (string, error) {
return foo()
}
更简单也更容易阅读(当然,除非你要对某些内部的值做一些记录)。
【==建议3.6.3==】 withContext 封装函数
有时对于函数会有一些重复劳动,例如锁/解锁,初始化一个新的局部上下文,准备初始化变量等等……这里有一个例子:
func foo() {
mu.Lock()
defer mu.Unlock()
// foo 相关的工作
}
func bar() {
mu.Lock()
defer mu.Unlock()
// bar 相关的工作
}
func qux() {
mu.Lock()
defer mu.Unlock()
// qux 相关的工作
}
如果你想要修改某个内容,你需要对所有的都进行修改。如果它是一个常见的任务,那么最好创建一个叫做withContext的函数。这个函数的输入参数是另一个函数,并用调用者提供的上下文来调用它:
func withLockContext(fn func()) {
mu.Lock
defer mu.Unlock()
fn()
}
只需要将之前的函数用这个进行封装:
func foo() {
withLockContext(func() {
// foo 相关工作
})
}
func bar() {
withLockContext(func() {
// bar 相关工作
})
}
func qux() {
withLockContext(func() {
// qux 相关工作
})
}
不要光想着加锁的情形。对此来说最好的用例是数据库链接。现在对 withContext 函数作一些小小的改动:
func withDBContext(fn func(db DB)) error {
// 从连接池获取一个数据库连接
dbConn := NewDB()
returnfn(dbConn)
}
如你所见,它获取一个连接,然后传递给提供的参数,并且在调用函数的时候返回错误。你需要做的只是:
func foo() {
withDBContext(func(db *DB) error {
// foo 相关工作
})
}
func bar() {
withDBContext(func(db *DB) error {
// bar 相关工作
})
}
func qux() {
withDBContext(func(db *DB) error {
// qux 相关工作
})
}
你在考虑一个不同的场景,例如作一些预初始化?没问题,只需要将它们加到withDBContext就可以了。这对于测试也同样有效。
这个方法有个缺陷,它增加了缩进并且更难阅读。再次提示,永远寻找最简单的解决方案。
3.7 参数
【建议3.7.1】参数按逻辑紧密程度安排位置, 同种类型的参数放在相邻位置。
举例:
func(m1, m2 *MenuEntry) bool
func (c *Client) Delete(key string, recursive bool, dir bool) (*RawResponse, error)
【建议3.7.2】避免使用标识参数来控制函数的执行逻辑。
举例:
func doAorB(flag int) {
if flag == flagA {
processA1()
return
}
if flag == flagB {
processB1()
return
}
}
特别是标识为布尔值时,通过标识参数控制函数内的逻辑,true执行这部分逻辑,false执行另外一部分逻辑,说明了函数职责不单一。
【建议3.7.3】参数个数不要超过5个
参数过多通常意味着缺少封装,不易维护,容易出错.
3.8 返回值
【规则3.8.1】函数返回值个数不要超过3个。
【建议3.8.2】如果函数的返回值超过3个,建议将其中关系密切的返回值参数封装成一个结构体。
3.9 注释
Go提供了C风格的块注释/* */和C++风格的行注释//。通常为行注释;块注释大多数作为程序包的注释,但也可以用于一个表达式中,或者用来注释掉一大片代码。
godoc用来处理Go源文件,抽取有关程序包内容的文档。在顶层声明之前出现,并且中间没有换行的注释,会随着声明一起被抽取,作为该项的解释性文本。这些注释的本质和风格决定了godoc所产生文档的质量。
Go代码的注释可以被godocs工具转化为文档发布。所以准确的代码注释除了能够帮助阅读代码还有助于代码手册的生成。
Godoc工具说明可参考如下链接:
https://godoc.org/golang.org/x/tools/cmd/godoc
通用注释要求
【原则3.9.1】编写代码首先考虑如何代码自我解释,然后才是添加注释进行补充说明
说明:优秀的代码不写注释也可轻易读懂,注释无法把糟糕的代码变好,需要很多注释来解释的代码往往存在坏味道,需要重构。
示例:注释不能消除代码的坏味道:
// 判断m是否为素数
// 返回值:: 1是素数,0不是素数
func p(m int) int {
var i, k int
k = sqrt(m)
for i = 2; i <= k; i++ {
if m%i == 0 {
break // 发现整除,表示m不为素数,结束遍历
}
}
// 遍历中没有发现整除的情况,返回
if i > k {
return 1
}
// 遍历中没有发现整除的情况,返回
return 0
}
重构代码后,不需要注释:
// IsPrimeNumber return true if num is prime
func IsPrimeNumber(num int) bool {
var i int
sqrtOfNum := sqrt(num)
for i = 2; i <= sqrtOfNum; i++ {
if num%i == 0 {
return false
}
}
return true
}
【原则3.9.2】注释的内容要清楚、明了,含义准确,防止注释二义性。
说明:有歧义的注释反而会导致维护者更难看懂代码,正如带两块表反而不知道准确时间。
示例:注释与代码相矛盾,注释内容也不清楚,前后矛盾。
// 上报网管时要求故障ID与恢复ID相一致
// 因此在此由告警级别获知是不是恢复ID
// 若是恢复ID则设置为ClearId,否则设置为AlarmId
if ClearAlarmLevel != rcData.level {
SetAlarmID(rcData.AlarmId);
} else {
SetAlarmID(rcData.ClearId);
}
正确做法:修改注释描述如下:
// 网管达成协议:上报故障ID与恢复ID由告警级别确定,若是清除级别,ID设置为ClearId,否则设为AlarmId
...
【原则3.9.3】在代码的功能、意图层次上进行注释,即注释用于解释代码难以直接表达的意图,而不是重复描述代码。
说明:注释的目的是解释代码的目的、功能和采用的方法,提供代码以外的信息,帮助读者理解代码,防止没必要的重复注释信息。
对于实现代码中巧妙的、晦涩的、有趣的、重要的地方加以注释。
注释不是为了名词解释(what),而是说明用途(why)。
示例:如下注释纯属多余。
i++ // increment i
if receiveFlag { // if receiveFlag is TRUE
...
如下这种无价值的注释不应出现(空洞的笑话,无关紧要的注释)。
// 时间有限,现在是:04,根本来不及想为什么,也没人能帮我说清楚
...
而如下的注释则给出了有用的信息:
//由于xx编号网上问题,在xx情况下,芯片可能存在写错误,此芯片进行写操作后,必须进行回读校验,如果回读不正确,需要再重复写-回读操作,最多重复三次,这样可以解决绝大多数网上应用时的写错误问题
time := 0
for (readReg(someAddr) != value) && (time < 3) {
writeReg(someAddr, value)
time++
}
对于实现代码中巧妙的、晦涩的、有趣的、重要的地方加以注释,出彩的或复杂的代码块前要加注释,如:
// Divide result by two, taking into account that x contains the carry from the add.
for i := 0; i < len(result); i++ {
x = (x << 8) + result[i]
result[i] = x >> 1
x &= 1
}
【规则3.9.4】所有导出对象都需要注释说明其用途;非导出对象根据情况进行注释。必须时,应该说明值的取值范围,及默认值。
【规则3.9.5】注释的单行长度不能超过 80 个字符。
【规则3.9.6】注释需要紧贴对应的包声明和函数之前,不能有空行、
【规则3.9.7】非跨度很长的注释,尽量使用 // 方式。
/*
* 1. 确保 template 存在
*/
改成:
// 1. 确保 template 存在
【规则3.9.8】避免多余的空格,两句注释之间保持一个空格。
示例:
// 采用这样的方式
// Sentence one. Sentence two.
// 而不是如下的方式
// Sentence one. Sentence two.
保持和Go的风格一样,参考https://golang.org/cl/20022
【原则3.9.9】注释第一条语句应该为一条概括语句,并且使用被声明的名字作为开头。
例如:
// Compile parses a regular expression and returns, if successful, a Regexp
// object that can be used to match against text.
func Compile(str string) (regexp *Regexp, err error) {
【建议3.9.10】//与注释的文档之间空一格。
示例:
// 采用如下方式
// This is a comment
// for humans.
//而不要采用如下方式:
//This is a comment
//for humans.
对于Go保留的语法,就不需要空一格
//go:generate go run gen.go
详细的语法可以参考:https://golang.org/cmd/compile/#hdr-Compiler_Directives.
包注释要求
【规则3.9.11】每个程序包都应该有一个包注释,一个位于package子句之前的块注释。
对于有多个文件的程序包,包注释只需要出现在一个文件中,任何一个文件都可以。包注释应该用来介绍该程序包,并且提供与整个程序包相关的信息。它将会首先出现在godoc页面上,并会建立后续的详细文档。
/*
Package regexp implements a simple library for regular expressions.
The syntax of the regular expressions accepted is:
regexp:
concatenation { '|' concatenation }
concatenation:
{ closure }
closure:
term [ '*' | '+' | '?' ]
term:
'^'
'$'
'.'
character
'[' [ '^' ] character-ranges ']'
'(' regexp ')'
*/
package regexp
如果程序包很简单,则包注释可以非常简短。
// Package path implements utility routines for
// manipulating slash-separated filename paths.
【规则3.9.12】不要依靠用空格进行对齐。
注释不需要额外的格式,例如星号横幅。生成的输出甚至可能会不按照固定宽度的字体进行展现,所以不要依靠用空格进行对齐—godoc,就像gofmt,会处理这些事情。注释是不作解析的普通文本,所以HTML和其它注解,例如this,将会逐字的被复制。对于缩进的文本,godoc确实会进行调整,来按照固定宽度的字体进行显示,这适合于程序片段。fmt package的包注释使用了这种方式来获得良好的效果。
根据上下文,godoc甚至可能不会重新格式化注释,所以要确保它们看起来非常直接:使用正确的拼写,标点,以及语句结构,将较长的行进行折叠,等等。
3.3.3 结构、接口及其他类型注释要求
【建议3.27】类型定义一般都以单数信息描述。
示例:
// Request represents a request to run a command.
type Request struct { ...
如果为接口,则一般以以下形式描述。
示例:
// FileInfo is the interface that describes a file and is returned by Stat and Lstat.
type FileInfo interface { ...
函数与方法注释要求
【建议3.9.13】函数声明处注释描述函数功能、性能及用法,包括输入和输出参数、函数返回值、可重入的要求等;定义处详细描述函数功能和实现要点,如实现的简要步骤、实现的理由、设计约束等
说明:重要的、复杂的函数,提供外部使用的接口函数应编写详细的注释。
【建议3.9.14】如果函数或者方法为判断类型(返回值主要为bool类型),则以 returns true if 开头。
如下例所示:
// HasPrefix returns true if name has any string in given slice as // prefix.
func HasPrefix(name string, prefixes []string) bool { ...
变量和常量的注释要求
Go的声明语法允许对声明进行组合。单个的文档注释可以用来介绍一组相关的常量或者变量。由于展现的是整个声明,这样的注释通常非常简单的。
// Error codes returned by failures to parse an expression.
var (
ErrInternal = errors.New("regexp: internal error")
ErrUnmatchedLpar = errors.New("regexp: unmatched '('")
ErrUnmatchedRpar = errors.New("regexp: unmatched ')'")
...
)
一般建议采用这样的方式:
var (
// BConfig is the default config for Application
BConfig *Config
// AppConfig is the instance of Config, store the config information from file
AppConfig *beegoAppConfig
// AppPath is the absolute path to the app
AppPath string
// GlobalSessions is the instance for the session manager
GlobalSessions *session.Manager
)
编码注释
在编码阶段应该同步写好 变量、函数、包 的注释,最后可以利用 godoc 命令导出文档。注释必须是完整的句子,句子的结尾应该用句号作为结尾(英文句号)。注释推荐用英文,可以在写代码过程中锻炼英文的阅读和书写能力。而且用英文不会出现各种编码的问题。
每个包都应该有一个包注释,一个位于 package 子句之前的块注释或行注释。包如果有多个 go 文件,只需要出现在一个 go 文件中即可。
// ping包实现了常用的ping相关的函数
package ping
Bug的注释
针对代码中出现的bug,可以采用如下教程使用特殊的注释,在godocs中可以做到注释高亮:
// BUG(astaxie):This divides by zero.
var i float = 1/0
http://blog.golang.org/2011/03/godocdocumentinggocode.html
带mutex的struct必须是指针receivers
如果你定义的struct中带有mutex,那么你的receivers必须是指针
recieved是值类型还是指针类型
到底是采用值类型还是指针类型主要参考如下原则:
func(w Win) Tally(playerPlayer)int //w不会有任何改变
func(w *Win) Tally(playerPlayer)int //w会改变数据
更多的请参考:https://code.google.com/p/go-wiki/wiki/CodeReviewComments#Receiver_Type
其他注释要求
- 当某个部分等待完成时,可用 TODO: 开头的注释来提醒维护人员。
- 当某个部分存在已知问题进行需要修复或改进时,可用 FIXME: 开头的注释来提醒维护人员。
- 当需要特别说明某个问题时,可用 NOTE: 开头的注释:
- 针对代码中出现的bug,可以采用BUG(who):注释,这些注释将被识别为已知的bug,并包含在文档的BUGS区。而其中的who应该是那些可以提供关于这个BUG更多信息的用户名。
比如,下面就是一个bytes包中已知的问题:
// BUG(r): The rule Title uses for word boundaries does not handle Unicode punctuation properly.
3.10 错误
【原则3.10.1】错误处理的原则就是不能丢弃任何有返回err的调用,不要采用_丢弃,必须全部处理。接收到错误,要么返回err,要么实在不行就panic,或者使用log记录下来
【规则3.10.2】error的信息不要采用大写字母,尽量保持你的错误简短,但是要足够表达你的错误的意思。
【规则3.10.3】导出的错误变量的命名,以Err开始,如ErrSomething,无需导出的错误变量命名,以Error作为后缀,如specificError
举例:
// 包级别的导出error.
var ErrSomething = errors.New("something went wrong")
func main() {
// 通常情况下我们只需要使用"err"
result, err := doSomething()
// 但是你也可以申明一个新的长名字变量,例如 "somethingError".
// Error作为后缀
var specificError error
result, specificError = doSpecificThing()
// ... 后面就使用specificError.
}
不好的例子:
var ErrorSomething = errors.New("something went wrong")
var SomethingErr = errors.New("something went wrong")
func main() {
var specificErr error
result, specificErr = doSpecificThing()
var errSpecific error
result, errSpecific = doSpecificThing()
var errorSpecific error
result, errorSpecific = doSpecificThing()
}
【规则3.10.4】公共包内禁止使用panic,如果有panic需要内部recover并返回error。
说明:只有当实在不可运行的情况采用panic,例如文件无法打开,数据库无法连接导致程序无法正常运行,但是对于其他的package对外的接口不能有panic。
3.11 其他
【建议3.11.1】在代码中编写字符串形式的json时,使用反单引号,而不是双引号。
例如:
"{"key":"value"}"
改为格式更清晰的:
`
{
"key":"value"
}
`
【规则3.11.2】相对独立的程序块之间、变量说明之后必须加空行,而逻辑紧密相关的代码则放在一起。
不好的例子:
func formatResponseBody(res *http.Response, httpreq *httplib.BeegoHttpRequest, pretty bool) string {
body, err := httpreq.Bytes()
if err != nil {
log.Fatalln("can't get the url", err)
}
match, err := regexp.MatchString(contentJsonRegex, res.Header.Get("Content-Type"))
if err != nil {
log.Fatalln("failed to compile regex", err)
}
if pretty && match {
var output bytes.Buffer
err := json.Indent(&output, body, "", " ")
if err != nil {
log.Fatal("Response Json Indent: ", err)
}
return output.String()
}
return string(body)
}
应该改为:
func formatResponseBody(res *http.Response, httpreq *httplib.BeegoHttpRequest, pretty bool) string {
body, err := httpreq.Bytes()
if err != nil {
log.Fatalln("can't get the url", err)
}
match, err := regexp.MatchString(contentJsonRegex, res.Header.Get("Content-Type"))
if err != nil {
log.Fatalln("failed to compile regex", err)
}
if pretty && match {
var output bytes.Buffer
err := json.Indent(&output, body, "", " ")
if err != nil {
log.Fatal("Response Json Indent: ", err)
}
return output.String()
}
return string(body)
}
提示:当你需要为接下来的代码增加注释的时候,说明该考虑加一行空行了。
【规则3.11.3】尽早return:一旦有错误发生,马上返回。
举例:不要使用
if err != nil {
// error handling
} else {
// normal code
}
而推荐使用:
if err != nil {
// error handling
return // or continue, etc.
}
// normal code
这样可以减少嵌套深度,代码更加美观。
【建议3.11.4】禁止出现2处及以上的重复代码。
如果出现,必须抽取为独立小函数。不要担心性能问题,编译器会帮你搞定大部分的内联优化。同时认真阅读第四章节的“代码质量保证优先原则”
【建议3.11.5】if条件判断, 同时使用超过3个表达式以上的时候, 使用switch替代。
例如:
if a == 0 || a == 1 || a == 2 || a == 3 {
// ...
}
建议改写为:
switch a {
case 0, 1, 2, 3:
// ....
}
【建议3.11.6】定义bool变量时,要避免判断时出现双重否定,应使用肯定形式的表达式。
举例:
if !notFailed && !isReported { // 晦涩,不容易理解
notifyUser()
} else {
process()
}
应改为:
if isSuccess || isReported {
process()
} else {
notifyUser()
}
【建议3.11.7】for循环初始值从0开始,判断条件使用<无等号的方式。
举例:
for i := 1; i <= 10; i++ {
doSomeThing()
}
应改为:
for i := 0; i < 10; i++ {
doSomeThing()
}
这样子可以迅速准确得出循环次数。
【建议3.11.8】长句子打印或者调用,使用参数进行格式化分行
我们在调用fmt.Sprint
或者log.Sprint
之类的函数时,有时候会遇到很长的句子,我们需要在参数调用处进行多行分割:
下面是错误的方式:
log.Printf(“A long format string: %s %d %d %s”, myStringParameter, len(a),
expected.Size, defrobnicate(“Anotherlongstringparameter”,
expected.Growth.Nanoseconds() /1e6))
应该是如下的方式:
log.Printf(
“A long format string: %s %d %d %s”,
myStringParameter,
len(a),
expected.Size,
defrobnicate(
“Anotherlongstringparameter”,
expected.Growth.Nanoseconds()/1e6,
),
)
【==建议3.11.9==】 将 for-select 封装到函数中
如果在某个条件下,你需要从 for-select 中退出,就需要使用标签。例如:
func main() {
L:
for{
select {
case<-time.After(time.Second):
fmt.Println("hello")
default:
break L
}
}
fmt.Println("ending")
}
如你所见,需要联合break使用标签。这有其用途,不过我不喜欢。这个例子中的 for 循环看起来很小,但是通常它们会更大,而判断break的条件也更为冗长。
如果需要退出循环,我会将 for-select 封装到函数中:
func main() {
foo()
fmt.Println("ending")
}
func foo() {
for{
select {
case<-time.After(time.Second):
fmt.Println("hello")
default:
return
}
}
}
你还可以返回一个错误(或任何其他值),也是同样漂亮的,只需要:
// 阻塞
if err := foo(); err != nil {
// 处理 err
}
【==建议3.11.10==】把 slice、map 等定义为自定义类型
将 slice 或 map 定义成自定义类型可以让代码维护起来更加容易。假设有一个Server类型和一个返回服务器列表的函数:
type Server struct{
Name string
}
func ListServers() []Server {
return[]Server{
{Name:"Server1"},
{Name:"Server2"},
{Name:"Foo1"},
{Name:"Foo2"},
}
}
现在假设需要获取某些特定名字的服务器。需要对 ListServers() 做一些改动,增加筛选条件:
// ListServers 返回服务器列表。只会返回包含 name 的服务器。空的 name 将会返回所有服务器。
func ListServers(name string) []Server {
servers := []Server{
{Name:"Server1"},
{Name:"Server2"},
{Name:"Foo1"},
{Name:"Foo2"},
}
// 返回所有服务器
if name == ""{
return servers
}
// 返回过滤后的结果
filtered := make([]Server, 0)
for_, server := range servers {
if strings.Contains(server.Name, name) {
filtered = append(filtered, server)
}
}
return filtered
}
现在可以用这个来筛选有字符串Foo的服务器:
func main() {
servers := ListServers("Foo")
// 输出:“servers [{Name:Foo1} {Name:Foo2}]”
fmt.Printf("servers %+v\n", servers)
}
显然这个函数能够正常工作。不过它的弹性并不好。如果你想对服务器集合引入其他逻辑的话会如何呢?例如检查所有服务器的状态,为每个服务器创建一个数据库记录,用其他字段进行筛选等等……
现在引入一个叫做Servers的新类型,并且修改原始版本的 ListServers() 返回这个新类型:
type Servers []Server
// ListServers 返回服务器列表
func ListServers() Servers {
return[]Server{
{Name:"Server1"},
{Name:"Server2"},
{Name:"Foo1"},
{Name:"Foo2"},
}
}
现在需要做的是只要为Servers类型添加一个新的Filter()方法:
// Filter 返回包含 name 的服务器。空的 name 将会返回所有服务器。
func (s Servers) Filter(name string) Servers {
filtered := make(Servers, 0)
for_, server := range s {
ifstrings.Contains(server.Name, name) {
filtered = append(filtered, server)
}
}
return filtered
}
现在可以针对字符串Foo筛选服务器:
func main() {
servers := ListServers()
servers = servers.Filter("Foo")
fmt.Printf("servers %+v\n", servers)
}
哈!看到你的代码是多么的简单了吗?还想对服务器的状态进行检查?或者为每个服务器添加一条数据库记录?没问题,添加以下新方法即可:
func (s Servers) Check()
func (s Servers) AddRecord()
func (s Servers) Len()
...
【==建议3.11.11==】 为访问 map 增加 setter,getters
如果你重度使用 map 读写数据,那么就为其添加 getter 和 setter 吧。通过 getter 和 setter 你可以将逻辑封分别装到函数里。这里最常见的错误就是并发访问。如果你在某个 goroutein 里有这样的代码:
m["foo"] = bar
还有这个:
delete(m,"foo")
会发生什么?你们中的大多数应当已经非常熟悉这样的竞态了。简单来说这个竞态是由于 map 默认并非线程安全。不过你可以用互斥量来保护它们:
mu.Lock()
m["foo"] ="bar"
mu.Unlock()
以及:
mu.Lock()
delete(m,"foo")
mu.Unlock()
假设你在其他地方也使用这个 map。你必须把互斥量放得到处都是!然而通过 getter 和 setter 函数就可以很容易的避免这个问题:
func Put(key, value string) {
mu.Lock()
m[key] = value
mu.Unlock()
}
func Delete(key string) {
mu.Lock()
delete(m, key)
mu.Unlock()
}
使用接口可以对这一过程做进一步的改进。你可以将实现完全隐藏起来。只使用一个简单的、设计良好的接口,然后让包的用户使用它们:
type Storage interface {
Delete(key string)
Get(key string) string
Put(key, value string)
}
这只是个例子,不过你应该能体会到。对于底层的实现使用什么都没关系。不光是使用接口本身很简单,而且还解决了暴露内部数据结构带来的大量的问题。
但是得承认,有时只是为了同时对若干个变量加锁就使用接口会有些过分。理解你的程序,并且在你需要的时候使用这些改进。
参数传递
【建议3.11.12】 对于少量数据,不要传递指针
【建议3.11.13】 对于大量数据的 struct 可以考虑使用指针
【建议3.11.14】 传入的参数是 map,slice,chan 不要传递指针,因为 map,slice,chan 是引用类型,不需要传递指针的指针
自定义类型的string循环问题
如果自定义的类型定义了String方法,那么在打印的时候会产生隐藏的一些bug
type MyInt int
func (m MyInt) String() string {
return fmt.Sprint(m) //BUG:死循环
}
func(m MyInt) String() string {
return fmt.Sprint(int(m)) //这是安全的,因为我们内部进行了类型转换
}
panic
尽量不要使用panic,除非你知道你在做什么
在逻辑处理中禁用panic
在main包中只有当实在不可运行的情况采用panic,例如文件无法打开,数据库无法连接导致程序无法
正常运行,但是对于其他的package对外的接口不能有panic,只能在包内采用。
强烈建议在main包中使用log.Fatal来记录错误,这样就可以由log来结束程序。
注意闭包的调用
【原则3.11.15】在循环中调用函数或者goroutine方法,一定要采用显示的变量调用,不要再闭包函数里面调用循环的参数
fori:=0;i<limit;i++{
go func(){ DoSomething(i) }() //错误的做法
go func(i int){ DoSomething(i) }(i)//正确的做法
}
参考:
http://golang.org/doc/articles/race_detector.html#Race_on_loop_counter
优化篇
说明:本篇的意义是为开发提供一些经过验证的开发规则和建议,让开发在开发过程中避免低级错误,从而提高代码的质量保证和性能效率
4.1 质量保证
4.1.1 代码质量保证优先原则
【原则4.1.1】代码质量保证优先原则:
(1)正确性,指程序要实现设计要求的功能。
(2)简洁性,指程序易于理解并且易于实现。
(3)可维护性,指程序被修改的能力,包括纠错、改进、新需求或功能规格变化的适应能力。
(4)可靠性,指程序在给定时间间隔和环境条件下,按设计要求成功运行程序的概率。
(5)代码可测试性,指软件发现故障并隔离、定位故障的能力,以及在一定的时间和成本前提下,进行测试设计、测试执行的能力。
(6)代码性能高效,指是尽可能少地占用系统资源,包括内存和执行时间。
(7)可移植性,指为了在原来设计的特定环境之外运行,对系统进行修改的能力。
4.1.2 对外接口原则
【原则4.1.2】对于主要功能模块抽象模块接口,通过interface提供对外功能。
说明:Go语言其中一个特殊的功能就是interface,它让面向对象,内容组织实现非常的方便。正确的使用这个特性可以使模块的可测试性和可维护性得到很大的提升。对于主要功能包(模块),在package包主文件中通过interface对外提供功能。
示例:在buffer包的buffer.go中定义如下内容
package buffer
import (
"policy_engine/models"
)
//other code …
type MetricsBuffer interface {
Store(metric *DataPoint) error
Get(dataRange models.MatchPolicyDataRange) (*MetricDataBuf, error)
Clear(redisKey string) error
Stop()
Stats() []MetrisBufferStat
GetByKey(metricKey string) []DataPoint
}
使用buffer package的代码示例,通过interface定义,可以在不影响调用者使用的情况下替换package。基于这个特性,在测试过程中,也可以通过实现符合interface要求的类来打桩实现测试目的。
package metrics
import (
...//other import
"policy_engine/worker/metrics/buffer"
)
type MetricsClient struct {
logger lager.Logger
redisClient *store.RedisClient
conf *config.Config
metricsBuffer buffer.MetricsBuffer //interface类型定义的成员
metricsStatClient *metricstat.MetricsStatClient
stopSignal chan struct{}
}
func New(workerId string, redisClient *store.RedisClient, logger lager.Logger, conf *config.Config) *MetricsClient {
var metricsBuffer MetricsBuffer
if conf.MetricsBufferConfig.StoreType == config.METRICS_MEM_STORE {
//具有interface定义函数的package实现,通过内存保存数据
metricsBuffer = NewMemBuffer(logger, conf)
} else if conf.MetricsBufferConfig.StoreType == config.METRICS_REDIS_STORE {
//具有interface定义函数的package实现,通过redis保存数据
metricsBuffer = NewRedisBuffer(redisClient, logger, conf)
} else {
... //other code
}
... //other code
}
4.1.3 值与指针(T/*T)的使用原则
关于接收者对指针和值的规则是这样的,值方法可以在指针和值上进行调用,而指针方法只能在指针上调用。这是因为指针方法可以修改接收者;使用拷贝的值来调用它们,将会导致那些修改会被丢弃。
对于使用T还是*T作为接收者,下面是一些建议:
【建议4.1.3.1】基本类型传递时,尽量使用值传递。
【建议4.1.3.2】如果传递字符串或者接口对象时,建议直接实例传递而不是指针传递。
【建议4.1.3.3】如果是map、func、chan,那么直接用T。
【建议4.1.3.4】如果是slice,method里面不重新reslice之类的就用T。
【建议4.1.3.5】如果想通过method改变里面的属性,那么请使用*T。
【建议4.1.3.6】如果是struct,并且里面包含了sync.Mutex之类的同步原语,那么请使用*T,避免copy。
【建议4.1.3.7】如果是一个大型的struct或者array,那么使用*T会比较轻量,效率更高。
【建议4.1.3.8】如果是struct、slice、array里面的元素是一个指针类型,然后调用函数又会改变这个数据,那么对于读者来说采用*T比较容易懂。
【建议4.1.3.9】其它情况下,建议采用*T。
参考:https://github.com/golang/go/wiki/CodeReviewComments#pass-values
4.1.4 init的使用原则
每个源文件可以定义自己的不带参数的init函数,来设置它所需的状态。init是在程序包中所有变量声明都被初始化,以及所有被导入的程序包中的变量初始化之后才被调用。
除了用于无法通过声明来表示的初始化以外,init函数的一个常用法是在真正执行之前进行验证或者修复程序状态的正确性。
【规则4.1.4.1】一个文件只定义一个init函数。
【规则4.1.4.2】一个包内的如果存在多个init函数,不能有任何的依赖关系。
注意如果包内有多个init,每个init的执行顺序是不确定的。
4.1.5 defer的使用原则
【建议4.1.5.1】如果函数存在多个返回的地方,则采用defer来完成如关闭资源、解锁等清理操作。
说明:Go的defer语句用来调度一个函数调用(被延期的函数),在函数即将返回之前defer才被运行。这是一种不寻常但又很有效的方法,用于处理类似于不管函数通过哪个执行路径返回,资源都必须要被释放的情况。典型的例子是对一个互斥解锁,或者关闭一个文件。
【建议4.1.5.2】defer会消耗更多的系统资源,不建议用于频繁调用的方法中。
【建议4.1.5.3】避免在for循环中使用defer。
说明:一个完整defer过程要处理缓存对象、参数拷贝,以及多次函数调用,要比直接函数调用慢得多。
错误示例:实现一个加解锁函数,解锁过程使用defer处理。这是一个非常小的函数,并且能够预知解锁的位置,使用defer编译后会使处理产生很多无用的过程导致性能下降。
var lock sync.Mutex
func testdefer() {
lock.Lock()
defer lock.Unlock()
}
func BenchmarkTestDefer(b *testing.B) {
for i := 0; i < b.N; i++ {
testdefer()
}
}
// 耗时结果
BenchmarkTestDefer 10000000 211 ns/op
推荐做法:如果能够明确函数退出的位置,可以选择不使用defer处理。保证功能不变的情况下,性能明显提升,是耗时是使用defer的1/3。
var lock sync.Mutex
func testdefer() {
lock.Lock()
lock.Unlock() // ## 【修改】去除defer
}
func BenchmarkTestDefer(b *testing.B) {
for i := 0; i < b.N; i++ {
testdefer()
}
}
// 耗时结果
BenchmarkTest" 30000000 43.5 ns/op
4.1.6 Goroutine使用原则
【规则4.1.6.1】确保每个goroutine都能退出。
说明:Goroutine是Go并行设计的核心,在实现功能时不可避免会使用到,执行goroutine时会占用一定的栈内存。
启动goroutine就相当于启动了一个线程,如果不设置线程退出的条件就相当于这个线程失去了控制,占用的资源将无法回收,导致内存泄露。
错误示例:示例中ready()启动了一个goroutine循环打印信息到屏幕上,这个goroutine无法终止退出。
package main
import (
"fmt"
"time"
)
func ready(w string, sec int) {
go func() { // ## 【错误】goroutine启动之后无法终止
for {
time.Sleep(time.Duration(sec) * time.Second)
fmt.Println(w, "is ready! ")
}
}()
}
func main() {
ready("Tea", 2)
ready("Coffee", 1)
fmt.Println("I'm waiting")
time.Sleep(5 * time.Second)
}
推荐做法:对于每个goroutine都需要有退出机制,能够通过控制goroutine的退出,从而回收资源。通常退出的方式有:
- 使用标志位的方式;
- 信号量;
- 通过channel通道通知;
注意:channel是一个消息队列,一个goroutine获取signal后,另一个goroutine将无法获取signal,以下场景下每个channel对应一个goroutine
package main
import (
"fmt"
"time"
)
func ready(w string, sec int, signal chan struct{}) {
go func() {
for {
select {
case <-time.Tick(time.Duration(sec) * time.Second):
fmt.Println(w, "is ready! ")
case <-signal: // 对每个goroutie增加一个退出选项
fmt.Println(w, "is close goroutine!")
return
}
}
}()
}
func main() {
signal1 := make(chan struct{}) // 增加一个signal
ready("Tea", 2, signal1)
signal2 := make(chan struct{}) // 增加一个signal
ready("Coffee", 1, signal2)
fmt.Println("I'm waiting")
time.Sleep(4 * time.Second)
signal1 <- struct{}{}
signal2 <- struct{}{}
time.Sleep(4 * time.Second)
}
【规则4.1.6.2】禁止在闭包中直接引用闭包外部的循环变量。
说明:Go语言的特性决定了它会出现其它语言不存在的一些问题,比如在循环中启动协程,当协程中使用到了循环的索引值,往往会出现意想不到的问题,通常需要程序员显式地进行变量调用。
for i := 0; i < limit; i++ {
go func() { DoSomething(i) }() //错误做法
go func(i int) { DoSomething(i)}(i) //正确做法
}
参考:http://golang.org/doc/articles/race_detector.html#Race_on_loop_counter
4.1.7 Channel使用原则
【规则4.1.7.1】传递channel类型的参数时应该区分其职责。
在只发送的功能中,传递channel类型限定为: c chan<- int
在只接收的功能中,传递channel类型限定为: c <-chan int
【规则4.1.7.2】确保对channel是否关闭做检查。
说明:在调用方法时不能想当然地认为它们都会执行成功,当错误发生时往往会出现意想不到的行为,因此必须严格校验并合适处理函数的返回值。例如:channel在关闭后仍然支持读操作,如果channel中的数据已经被读取,再次读取时会立即返回0值与一个channel关闭指示。如果不对channel关闭指示进行判断,可能会误认为收到一个合法的值。因此在使用channel时,需要判断channel是否已经关闭。
错误示例:下面代码中若cc已被关闭,如果不对cc是否关闭做检查,则会产生死循环。
package main
import (
"errors"
"fmt"
"time"
)
func main() {
var cc = make(chan int)
go client(cc)
for {
select {
case <-cc: //## 【错误】当channel cc被关闭后如果不做检查则造成死循环
fmt.Println("continue")
case <-time.After(5 * time.Second):
fmt.Println("timeout")
}
}
}
func client(c chan int) {
defer close(c)
for {
err := processBusiness()
if err != nil {
c <- 0
return
}
c <- 1
}
}
func processBusiness() error {
return errors.New("domo")
}
推荐做法:对通道增加关闭判断。
// 前面代码略……
for {
select {
case _, ok := <-cc:
// 增加对chnnel关闭的判断,防止死循环
if ok == false {
fmt.Println("channel closed")
return
}
fmt.Println("continue")
case <-time.After(5 * time.Second):
fmt.Println("timeout")
}
}
// 后面代码略……
【规则4.1.7.3】禁止重复释放channel。
说明:重复释放channel会触发run-time panic,导致程序异常退出。重复释放一般存在于异常流程判断中,如果恶意攻击者能够构造成异常条件,则会利用程序的重复释放漏洞实施DoS攻击。
错误示例:
func client(c chan int) {
defer close(c)
for {
err := processBusiness()
if err != nil {
c <- 0
close(c) // ## 【错误】可能会产生双重释放
return
}
c <- 1
}
}
推荐做法:确保创建的channel只释放一次。
func client(c chan int) {
defer close(c)
for {
err := processBusiness()
if err != nil {
c <- 0 // ## 【修改】使用defer延迟close后,不再单独进行close
return
}
c <- 1
}
}
4.1.8 其它
【建议4.1.8.1】使用go vet --shadow检查变量覆盖,以避免无意的变量覆盖。
GO的变量赋值和声明可以通过”:=”同时完成,但是由于Go可以初始化多个变量,所以这个语法容易引发错误。下面的例子是一个典型的变量覆盖引起的错误,第二个val的作用域只限于for循环内部,赋值没有影响到之前的val。
package main
import "fmt"
import "strconv"
func main() {
var val int64
if val, err := strconv.ParseInt("FF", 16, 64); nil != err {
fmt.Printf("parse int failed with error %v\n", err)
} else {
fmt.Printf("inside : val is %d\n", val)
}
fmt.Printf("outside : val is %d \n", val)
}
执行结果:
inside : val is 255
outside : val is 0
正确的做法:
package main
import "fmt"
import "strconv"
func main() {
var val int64
var err error
if val, err = strconv.ParseInt("FF", 16, 64); nil != err {
fmt.Printf("parse int failed with error %v\n", err)
} else {
fmt.Printf("inside : val is %d\n", val)
}
fmt.Printf("outside : val is %d \n", val)
}
执行结果:
inside : val is 255
outside : val is 255
【建议4.1.8.2】GO的结构体中控制使用Slice和Map。
GO的slice和map等变量在赋值时,传递的是引用。从结果上看,是浅拷贝,会导致复制前后的两个变量指向同一片数据。这一点和Go的数组、C/C++的数组行为不同,很容易出错。
package main
import "fmt"
type Student struct {
Name string
Subjects []string
}
func main() {
sam := Student{
Name: "Sam", Subjects: []string{"Math", "Music"},
}
clark := sam //clark.Subject和sam.Subject是同一个Slice的引用!
clark.Name = "Clark"
clark.Subjects[1] = "Philosophy" //sam.Subject[1]也变了!
fmt.Printf("Sam : %v\n", sam)
fmt.Printf("Clark : %v\n", clark)
}
执行结果:
Sam : {Sam [Math Philosophy]}
Clark : {Clark [Math Philosophy]}
作为对比,请看作为Array定义的Subjects的行为:
package main
import "fmt"
type Student struct {
Name string
Subjects [2]string
}
func main() {
var clark Student
sam := Student{
Name: "Sam", Subjects: [2]string{"Math", "Music"},
}
clark = sam //clark.Subject和sam.Subject不同的Array
clark.Name = "Clark"
clark.Subjects[1] = "Philosophy" //sam.Subject不受影响!
fmt.Printf("Sam : %v\n", sam)
fmt.Printf("Clark : %v\n", clark)
}
执行结果:
Sam : {Sam [Math Music]}
Clark : {Clark [Math Philosophy]}
编写代码时,建议这样规避上述问题:
- 结构体内尽可能不定义Slice、Maps成员;
- 如果结构体有Slice、Maps成员,尽可能以小写开头、控制其访问;
- 结构体的赋值和复制,尽可能通过自定义的深度拷贝函数进行;
【规则4.1.8.3】避免在循环引用调用 runtime.SetFinalizer。
说明:指针构成的 "循环引用" 加上 runtime.SetFinalizer 会导致内存泄露。
runtime.SetFinalizer用于在一个对象 obj 被从内存移除前执行一些特殊操作,比如写到日志文件中。在对象被 GC 进程选中并从内存中移除以前,SetFinalizer 都不会执行,即使程序正常结束或者发生错误。
错误示例:垃圾回收器能正确处理 "指针循环引用",但无法确定 Finalizer 依赖次序,也就无法调用Finalizer 函数,这会导致目标对象无法变成不可达状态,其所占用内存无法被回收。
package main
import (
"fmt"
"runtime"
"time"
)
type Data struct {
d [1024 * 100]byte
o *Data
}
func test() {
var a, b Data
a.o = &b
b.o = &a
// ## 【错误】循环和SetFinalize同时使用
runtime.SetFinalizer(&a, func(d *Data) { fmt.Printf("a %p final.\n", d) })
runtime.SetFinalizer(&b, func(d *Data) { fmt.Printf("b %p final.\n", d) })
}
func main() {
for { // ## 【错误】循环和SetFinalize同时使用
test()
time.Sleep(time.Millisecond)
}
}
通过跟踪GC的处理过程,可以看到如上代码内存在不断的泄露:
go build -gcflags "-N -l" && GODEBUG="gctrace=1" ./test
gc11(1): 2+0+0 ms, 104 -> 104 MB 1127 -> 1127 (1180-53) objects
gc12(1): 4+0+0 ms, 208 -> 208 MB 2151 -> 2151 (2226-75) objects
gc13(1): 8+0+1 ms, 416 -> 416 MB 4198 -> 4198 (4307-109) objects
以上结果标红的部分代表对象数量,我们在代码中申请的对象都是局部变量,在正常处理过程中GC会持续的回收局部变量占用的内存。但是在当前的处理过程中,内存无法被GC回收,目标对象无法变成不可达状态。
推荐做法:需要避免内存指针的循环引用以及runtime.SetFinalizer同时使用。
【规则4.1.8.4】避免在for循环中使用time.Tick()函数。
如果在for循环中使用time.Tick(),它会每次创建一个新的对象返回,应该在for循环之外初始化一个ticker后,再在循环中使用:
ticker := time.Tick(time.Second)
for {
select {
case <-ticker:
// …
}
}
4.2 性能效率
4.2.1 Memory优化
【建议4.2.1.1】将多次分配小对象组合为一次分配大对象。
比如, 将 *bytes.Buffer 结构体成员替换为bytes。缓冲区 (你可以预分配然后通过调用bytes.Buffer.Grow为写做准备) 。这将减少很多内存分配(更快)并且减缓垃圾回收器的压力(更快的垃圾回收) 。
【建议4.2.1.2】将多个不同的小对象绑成一个大结构,可以减少内存分配的次数。
比如:将
for k, v := range m {
k, v := k, v // copy for capturing by the goroutine
go func() {
// use k and v
}()
}
替换为:
for k, v := range m {
x := struct{ k, v string }{k, v} // copy for capturing by the goroutine
go func() {
// use x.k and x.v
}()
}
这就将多次内存分配(分别为k、v分配内存)替换为了一次(为x分配内存)。然而,这样的优化方式会影响代码的可读性,因此要合理地使用它。
【建议4.2.1.3】组合内存分配的一个特殊情形是对分片数组进行预分配。
如果清楚一个特定的分片的大小,可以对数组进行预分配:
type X struct {
buf []byte
bufArray [16]byte // Buf usually does not grow beyond 16 bytes.
}
func MakeX() *X {
x := &X{}
// Preinitialize buf with the backing array.
x.buf = x.bufArray[:0]
return x
}
【建议4.2.1.4】尽可能使用小数据类型,并尽可能满足硬件流水线(Pipeline)的操作,如对齐数据预取边界。
说明:不包含任何指针的对象(注意 strings,slices,maps 和 chans 包含隐含指针)不会被垃圾回收器扫描到。
比如,1GB 的分片实际上不会影响垃圾回收时间。因此如果你删除被频繁使用的对象指针,它会对垃圾回收时间造成影响。一些建议:使用索引替换指针,将对象分割为其中之一不含指针的两部分。
【建议4.2.1.5】使用对象池来重用临时对象,减少内存分配。
标准库包含的sync.Pool类型可以实现垃圾回收期间多次重用同一个对象。然而需要注意的是,对于任何手动内存管理的方案来说,不正确地使用sync.Pool会导致 use-after-free bug。
4.2.2 GC 优化
【建议4.2.2.1】设置GOMAXPROCS为CPU的核心数目,或者稍高的数值。
GC是并行的,而且一般在并行硬件上具有良好可扩展性。所以给 GOMAXPROCS 设置较高的值是有意义的,就算是对连续的程序来说也能够提高垃圾回收速度。但是,要注意,目前垃圾回收器线程的数量被限制在 8 个以内。
【建议4.2.2.2】避免频繁创建对象导致GC处理性能问题。
说明:尽可能少的申请内存,减少内存增量,可以减少甚至避免GC的性能冲击,提升性能。
Go语言申请的临时局部变量(对象)内存,都会受GC(垃圾回收)控制内存的回收,其实我们在编程实现功能时申请的大部分内存都属于局部变量,所以与GC有很大的关系。
Go在GC的时候会发生Stop the world,整个程序会暂停,然后去标记整个内存里面可以被回收的变量,标记完成之后再恢复程序执行,最后异步地去回收内存。(暂停的时间主要取决于需要标记的临时变量个数,临时变量数量越多,时间越长。Go 1.7以上的版本大幅优化了GC的停顿时间, Go 1.8下,通常的GC停顿的时间<100μs)
目前GC的优化方式原则就是尽可能少的声明临时变量:
- 局部变量尽量利用
- 如果局部变量过多,可以把这些变量放到一个大结构体内,这样扫描的时候可以只扫描一个变量,回收掉它包含的很多内存
本规则所说的创建对象包含:
- &obj{}
- new(abc{})
- make()
我们在编程实现功能时申请的大部分内存都属于局部变量,下面这个例子说明的是我们实现功能时需要注意的一个问题,适当的调整可以减少GC的性能消耗。
错误示例:
代码中定义了一个tables对象,每个tables对象里面有一堆类似tableA和tableC这样的一对一的数据,也有一堆类似tableB这样的一对多的数据。假设有1万个玩家,每个玩家都有一条tableA和一条tableC的数据,又各有10条tableB的数据,那么将总的产生1w (tables) + 1w (tableA) + 1w (tableC) + 10w (tableB)的对象。
不好的例子:
// 对象数据表的集合
type tables struct {
tableA *tableA
tableB *tableB
tableC *tableC
// 此处省略一些表
}
// 每个对象只会有一条tableA记录
type tableA struct {
fieldA int
fieldB string
}
// 每个对象有多条tableB记录
type tableB struct {
city string
code int
next *tableB // 指向下一条记录
}
// 每个对象只有一条tableC记录
type tableC struct {
id int
value int64
}
建议一对一表用结构体,一对多表用slice,每个表都加一个_is_nil的字段,用来表示当前的数据是否是有用的数据,这样修改的结果是,一万个玩家,产生的对象总量是1w(tables)+1w([]tablesB),跟前面的差别很明显:
// 对象数据表的集合
type tables struct {
tableA tableA
tableB []tableB
tableC tableC
// 此处省略一些表
}
// 每个对象只会有一条tableA记录
type tableA struct {
_is_nil bool
fieldA int
fieldB string
}
// 每个对象有多条tableB记录
type tableB struct {
_is_nil bool
city string
code int
next *tableB // 指向下一条记录
}
// 每个对象只有一条tableC记录
type tableC struct {
_is_nil bool
id int
value int64
}
4.2.3 其它优化建议
【建议4.2.3.1】减少[]byte和string之间的转换,尽量使用[]byte来处理字符。
说明:Go里面string类型是immutable类型,而[]byte是切片类型,是可以修改的,所以Go为了保证语法上面没有二义性,在string和[]byte之间进行转换的时候是一个实实在在的值copy,所以我们要尽量的减少不必要的这个转变。
下面这个例子展示了传递slice但是进行了string的转化,
func PrefixForBytes(b []byte) string {
return "Hello" + string(b)
}
所以我们可以有两种方式,一种是保持全部的都是slice的操作,如下:
func PrefixForBytes(b []byte) []byte {
return append([]byte(“Hello”,b…))
}
还有一种就是全部是string的操作方式
func PrefixForBytes(str string) string {
return "Hello" + str
}
推荐阅读:https://blog.golang.org/strings
【建议4.2.3.2】make申请slice/map时,根据预估大小来申请合适内存。
说明:map和数组不同,可以根据新增的<key,value>对动态的伸缩,因此它不存在固定长度或者最大限制。
map的空间扩展是一个相对复杂的过程,每次扩容会增加到上次大小的两倍。它的结构体中有一个buckets和oldbuckets,用来实现增量扩容,正常情况下直接使用buckets,oldbuckets为空,如果当前哈希表正在扩容,则oldbuckets不为空,且buckets大小是oldbuckets大小的两倍。对于大的map或者会快速扩张的map,即便只是大概知道容量,也最好先标明。
slice是一个C语言动态数组的实现,在对slice进行append等操作时,可能会造成slice的自动扩容,其扩容规则:
- 如果新的大小是当前大小2倍以上,则大小增长为新大小
- 否则循环以下操作:如果当前大小小于1024,按每次2倍增长,否则每次按当前大小1/4增长,直到增长的大小超过或者等于新大小
推荐做法:在初始化map时指明map的容量。
- map := make(map[string]float, 100)
【建议4.2.3.3】字符串拼接优先考虑bytes.Buffer。
Golang字符串拼接常见有如下方式:
- fmt.Sprintf
- strings.Join
- string +
- bytes.Buffer
fmt.Sprintf会动态解析参数,效率通常是最差的,而string是只读的,string+会导致多次对象分配与值拷贝,而bytes.Buffer在预设大小情况下,通常只会有一次拷贝和分配,不会重复拷贝和复制,故效率是最佳的。
推荐做法:优先使用bytes.Buffer,非关键路径,若考虑简洁,可考虑其它方式,比如错误日志拼接使用fmt.Sprintf,但接口日志使用就不合适。
【建议4.2.3.4】避免使用CGO或者减少跨CGO调用次数。
说明:Go可以调用C库函数,但是Go带有垃圾收集器且Go的栈是可变长,跟C实际是不能直接对接的,Go的环境转入C代码执行前,必须为C新创建一个新的调用栈,把栈变量赋值给C调用栈,调用结束后再拷贝回来,这个调用开销非常大,相比直接GO语言调用,单纯的调用开销,可能有2个甚至3个数量级以上,且Go目前还存在版本兼容性问题。
推荐做法:尽量避免使用CGO,无法避免时,要减少跨CGO调用次数。
【建议4.2.3.5】避免高并发调用同步系统接口。
说明:编程世界同步场景更普遍,GO提供了轻量级的routine,用同步来模拟异步操作,故在高并发下的,相比线程,同步模拟代价比较小,可以轻易创建数万个并发调用。然而有些API是系统函数,而这些系统函数未提供异步实现,程序中最常见的posix规范的文件读写都是同步,epoll异步可解决网络IO,而对regular file是无法工作的。Go的运行时环境不可能提供超越操作系统API的能力,它依赖于系统syscall文件中暴露的api能力,而1.6版本还是多线程模拟,线程创建切换的代价也非常巨大,开源库中有filepoller来模拟异步其实也基于这两种思路,效率上也会大打折扣。
推荐做法:把诸如写文件这样的同步系统调用,要隔离到可控的routine中,而不是直接高并发调用。
【建议4.2.3.6】高并发时避免共享对象互斥。
说明:在Go中,可以轻易创建10000个routine而对系统资源通常就是100M的内存要求,但是并发数多了,在多线程中,当并发冲突在4个到8个线程间时,性能可能就开始出现拐点,急剧下降,这同样适应于Go,Go可以轻易创建routine,但对并发冲突的风险必须要做实现的处理。
推荐做法:routine需要是独立的,无冲突的执行,若routine间有并发冲突,则必须控制可能发生冲突的并发routine个数,避免出现性能恶化拐点。
【建议4.2.3.7】长调用链或在函数中避免申明较多较大临时变量。
routine的调用栈默认大小1.7版本已修改为2K,当栈大小不够时,Go运行时环境会做扩栈处理,创建10000个routine占用空间才20M,所以routine非常轻量级,可以创建大量的并发执行逻辑。而线程栈默认大小是1M,当然也可以设置到8K(有些系统可以设置4K),一般不会这么做,因为线程栈大小是固定的,不能随需而变大,不过实际CPU核一般都在100以内,线程数是足够的。
routine是怎么实现可变长栈呢?当栈大小不够时,它会新创建一个栈,通常是2倍大小增长,然后把栈赋值过来,而栈中的指针变量需要搜索出来重新指向新的栈地址,好处不是随便有的,这里就明显有性能开销,而且这个开销不小。
说明:频繁创建的routine,要注意栈生长带来的性能风险,比如栈最终是2M大小,极端情况下就会有数10次扩栈操作,从而让性能急剧下降。所以必须控制调用栈和函数的复杂度,routine就意味着轻量级。
对于比较稳定的routine,也要注意它的栈生长后会导致内存飙升。
【建议4.2.3.8】为高并发的轻量级任务处理创建routine池。
说明:Routine是轻量级的,但对于高并发的轻量级任务处理,频繁创建routine来执行,执行效率也是非常低效率的。
推荐做法:高并发的轻量级任务处理,需要使用routine池,避免对调度和GC带来冲击。
【建议4.2.3.9】建议版本提供性能/内存监控的功能,并动态开启关闭,但不要长期开启pprof提供的CPU与MEM profile功能。
Go提供了pprof工具包,可以运行时开启CPU与内存的profile信息,便于定位热点函数的性能问题,而MEM的profile可以定位内存分配和泄漏相关问题。开启相关统计,跟GC一样,也会严重干扰性能,因而不要长期开启。
推荐做法:做测试和问题定位时短暂开启,现网运行,可以开启短暂时间收集相关信息,同时要确保能够自动关闭掉,避免长期打开。