这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天
高质量编程
高质量代码:编写的代码能够达到正确可好、简洁清晰的目标可称之为高质量代码。
- 各种编写条件是否考虑完备
- 异常情况处理、稳定性保证
- 易读易维护
编程原则:简单性、可读性、生产力
简单性:消除多余的复杂性,简单的逻辑代码有利于理解,在项目代码接手的过程中会省去很多麻烦;另一方面简单的代码有利于代码的进一步优化
可读性:代码需要给人来阅读,好的代码应该是简单可理解可读的
生产力:项目是一个团队工作,如果能够有统一的代码规范,对于提高整体的工作效率十分有效
编码规范
高质量代码的规范,从代码格式、注释、命名规范、控制流程、错误和异常处理,这5个方面来描述规范。
代码格式
- 使用 gofmt 自动格式化工具来格式化代码
- 使用 goimport 来完成对依赖包管理,例如,对 go.mod 文件中依赖的排序等等
注释
好的代码有很多注释,坏的代码需要很多注释 。
注释的需要做的事情:注释应该提供未表达出来的上下文
- 作用:解释代码作用
- 实现:解释代码是如何做的
- 原因:解释代码实现的原因
- 错误:解释代码什么时候出错
-
公共符号始终需要注释
-
包中声明的每个公共符号:变量、常量、函数以及结构体都需要注释
-
任何既不明显也不简单的公共功能需要注释
-
对于库中的任何函数都必须进行注释
-
不要注释实现接口的方法,因为接口已经很好的描述函数的行为了
-
-
注释应该解释代码的作用
注释清楚代码做了些什么事情,如果返回什么样的值代表什么样的状态等等
// WriteString writes the contents of the string s to w, which accepts a slice of bytes. // If w implements StringWriter, its WriteString method is invoked directly. // Otherwise, w.Write is called exactly once. func WriteString(w Writer, s string) (n int, err error) { if sw, ok := w.(StringWriter); ok { return sw.WriteString(s) } return w.Write([]byte(s)) } -
注释应该解释代码是如何做的
代码的码中复杂的,难以理解的代码段需要解释代码做了什么事情
// If the reader has a WriteTo method, use it to do the copy. // Avoids an allocation and a copy. if wt, ok := src.(WriterTo); ok { return wt.WriteTo(dst) } // Similarly, if the writer has a ReadFrom method, use it to do the copy. if rt, ok := dst.(ReaderFrom); ok { return rt.ReadFrom(src) } -
解释代码实现的原因
代码中某个行为,例如
shouldRedirect = fasle但就一个条件和一个语句没办法判断代码 -
解释代码什么时候会出错
解释代码的一些限制条件,解释代码会出错的情况。例如下面这个函数就详细解释了袭击的一些行为。在函数使用的过程中没必要去研究这个函数的实现,提高工作效率。
// parseTimeZone parses a time zone string and returns its length. Time zones // are human-generated and unpredictable. We can't do precise error checking. // On the other hand, for a correct parse there must be a time zone at the // beginning of the string, so it's almost always true that there's one // there. We look at the beginning of the string for a run of upper-case letters. // If there are more than 5, it's an error. // If there are 4 or 5 and the last is a T, it's a time zone. // If there are 3, it's a time zone. // Otherwise, other than special cases, it's not a time zone. // GMT is special because it can have an hour offset. func parseTimeZone(value string) (length int, ok bool)
命名规范
好的命名就像一个好的笑话。 如果你必须解释他,那么他就不好笑了。
好的命名有助于降低代码的理解成本。命名的时候需要重点考虑,命名的上下文信息和简洁性。
-
变量(variable)
-
简洁胜于冗长
-
缩略词全部大写,但是当其位于变量开头且不需要导出时,那么全部小写
使用 ServerHTTP 而不是 ServerHttp 使用 XMLHTTPRequest 或者 xmlHTTPRequest -
变量距离其被使用的地方越远就需要携带越多的上下文信息
例如,全局变量在名称中就需要更多上下文信息,使得在不同的地方可以轻易辨认出其含义。直观来说,越是全局变量,变量的名称也就越长
-
-
函数(function)
- 函数名不懈怠上下文信息,函数总是和包名成对出现
- 函数名尽可能简短
package http func Serve(I net.Listener, handler Handler) error func ServeHTTP(I net.Listener, handler Handler) error对于上面两种命名显然下面的命名是比较好的,包名本身携带了上下文信息,所以没必要将上下文信息保留在函数名中。
-
包(package)
- 只由小写字母组成,不包含大写字母和下划线等特殊字符
- 简短并包含一定的上下文信息,例如
http,task,time等等 - 不要和标准库同名
下面是一些约定俗成的约定:
- 不使用常用变量名作为包名
- 使用单数名词,不要使用复数
- 谨慎使用缩写,使用大家约定的缩写,例如
encoding不是encodings
控制流程
控制流程控制原则:
- 线性原理,逻辑尽量走直线,避免复杂的嵌套
- 正常流程沿着屏幕向下移动
- 提升代码可读性和可维护性
- 故障问题大多数出现在复杂的条件语句和循环语句中
两个案例:
-
减少代码的嵌套情况,确保代码清晰
可以使用
return语句的特性减少代码的嵌套情况// Bad if (err == nil) { return a } else { return b } // Good if (err == nil) { return a } return b -
保持正常代码路径为最小缩进
对于错误的判断往往需要优先处理,尽早返回或者继续循环来减少嵌套
// Bad func OneFunc() error { err := doSomething() if (err == nil) { err := doAnotherThing() if (err == nil) { return nil // normal case } return err } return err } // Good func OneFunc() error { if (err := doSomething(); err != nil) { return err } if (err := doAnotherThing(); err != nil) { return err } return nil // normal case }
错误和异常处理
-
简单错误
- 简单错误指的是仅仅出现一次的错误,而且在其他地方不需要捕获该错误
- 优先使用
errors.New()创建匿名变量来直接表示简单错误 - 如果有格式化需要那么就使用
fmt.Errorf()
errors.New("simple error") fmt.Errorf("ERROR CODE %d", code) -
错误的 Wrap 和 Unwrap
-
错误的包装,实际上提供了一个 error 嵌套另一个 error 的能力,从而生成一个 error 的追踪链
-
在
fmt.Errorf中使用%w符号来将一个错误关联至另一个错误链中
err := doSomething() fmt.Errorf("another error: %w", err)在 Go 1.13 中,在
errors中新增了三个新的 API 和一个新的 format 关键字,分别是errors.Iserrors.Aserrors.Unwrap和fmt.Errorf中的%w。 -
-
错误判定
判断一个错误是否为特定种类的错误:
errors.Is()在错误链上获取特定种类的错误:
errors.As() -
panic和recover-
在业务代码中不建议使用
panic -
调用函数发生了 panic 会造成程序的崩溃,如果问题可以屏蔽或者解决,建议使用error 代替 panic
-
panic 一般出现在程序启动阶段发生不可逆的错误时,可以在 init 或者 main 函数中使用
panic -
recover只能在被defer的函数中使用,嵌套无法生效,recover只在当前的 goroutine 生效 -
如果需要跟多的上下文信息,可以在
recover()后在 log 中进行记录当前的调用栈。defer func() { if e := recover(); e != nil { f = nil err = fmt.Errorf("panic: %v\n%s", e, debug.Stack()) }() }
Note:
defer语句根据定义的顺序,按照后进先出的顺序执行 -
注意事项
- error 尽可能提供键帽的上下文信息链,方便定位问题
- panic 用于真正异常的情况。这种异常一般导致程序无法正常启动
- recover 生效范围:在当前 goroutine 的被 defer 的函数中生效
性能优化
性能优化的前提是满足正确可靠,简洁清晰;性能优化是综合上的评估,有时候时间效率和空间效率可能是对立的
性能优化建议
对于性能上的测试,可以使用 Benchmark 来完成,在 Go 语言基础的学习中已经提到。
goos: windows
goarch: amd64
pkg: github.com/wangkechun/go-by-example/fib
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkFib10-8 4762810 248.0 ns/op 0 B/op 0 allocs/op
测试函数和GOMAXPROC值 b.N 每次运行的时间 每次执行分配的内存数量 每次执行分配内存次数
GOMAXPROC:在1.5版本后,默认值为 CPU 的核心数
Slice
-
尽可能在使用
make()初始化切片的时候提供容量信息data := make([]int, 0, size) for k := 0; k < size; k++ { data = append(data, k) }- 切片的本质是一个数组片段的描述
- 数组指针:
array - 片段长度:
len - 片段容量:
cap
- 数组指针:
- 切片操作不会复制切片指向的元素
- 创建一个新的切片会复用原来切片的底层数组
- 切片的本质是一个数组片段的描述
-
大内存未释放
根据切片的性质,创建新的切片会复用原来切片的底层数组,这样就导致大数组无法被及时释放出去;因此对于切片来说,可以使用
copy()来替代 re-slice 创建切片。result := make([]int, 2) // 直接复制内容创建切片 copy(result, origin[len(origin)-2:]) return result
Map 预分配内存
- 不断向 map 中添加元素的操作会触发 map 扩容
- 提前分配好空间可以减少内存拷贝和 Rehash 的性能消耗
- 最好是根据实际情况提前分配好需要的空间
字符串处理
-
使用
string.Builder进行字符串的拼接func StrBuilder(n int, str string) string { var builder strings.Builder for i := 0; i < n; i++ { builder.WriteString(str) } return builder.String }使用
+进行拼接的性能最差,strings.Builder,bytes.Buffer相近,strings.Builder更快- 字符串在 Go 语言中是不可变类型,占用内存大小是固定的
- 使用
+每次都会重新分配内存 strings.Builder,bytes.Buffer底层都是[]byte- 内存扩容策略,不需要每次拼接重新分配内存
-
strings.Builder的内存预分配var builder strings.Builder builder.Grow(n * len(str))
空结构体
使用空结构体可以节省内存。空结构体 string{} 实例不占据任何内存空间,可以在下面的几个场景作为占位符使用:
- 节省资源
- 空结构体本身具备很强的语义,也就是这里不需要任何的值,仅仅为占位符
// 这两种请路况中:bool 作为键会使用更多的内存空间
// bool 值即使占用空间小也需要 1 字节来存储
m := make(map[int]struct{})
m := make(map[int]bool)
实现 Set,可以考虑从使用map来代替,对于map的值可以使用空结构体减少内存占用。
atomic 包
type atomicCounter struct {
i int32
}
func AtomicIncrement(c *atomicCounter) {
atomic.AddInt32(&c.i, 1)
}
锁的实现通过操作系统调用来实现,属于系统调用,性能较低。atomic 包通过硬件实现,效率比锁要高。
sync.Mutex 应该保护的是临界区的代码,而不是去保护一个变量
对于非数值操作,可以使用 atomic.Value 这个可以承载一个 interface{}。
注意事项
- 避免出现常见的性能陷阱就可以保证大部分程序的性能
- 普通的代码没必要一味追求程序性能
- 越高级的性能优化手段越容易出现问题
- 在满足正确可靠,简洁清晰的质量要求下提高程序的性能
性能分析工具
pprof 性能分析工具:golang pprof 实战
打开 pprof 排查工具
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10
CPU
-
top:查看当前CPU占用情况
列名 含义 flat 当前函数本身的时间耗时 flat% flat 占用 CPU时间的比例 sum% 上面一行的 flat% 总和 cum 当前函数本身加上其调用函数的总耗时 cum% cum 占 CPU 总时间比例 flat == cum:当前函数未调用其他函数flat == 0:当前函数只调用了其他函数,自身没有占用时间 -
list:查找具体的代码行
-
web:展示当前调用链的调用情况
内存
使用命令打开UI
# 堆内存情况
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
# 内存分配情况
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/allocs
Goroutine
使用命令打开UI
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine
Mutex 锁情况
使用命令打开UI
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/mutex