敢问路在何方
原文
Go 开发关键技术指南 | 敢问路在何方?(内含超全知识大图)-阿里云开发者社区 (aliyun.com) (opens new window)
# Engineering
我觉得 Go 在工程上良好的支持,是 Go 能够在服务器领域有一席之地的重要原因。这里说的工程友好包括:
- gofmt 保证代码的基本一致,增加可读性,避免在争论不清楚的地方争论;
- 原生支持的 profiling,为性能调优和死锁问题提供了强大的工具支持;
- utest 和 coverage,持续集成,为项目的质量提供了良好的支撑;
- example 和注释,让接口定义更友好合理,让库的质量更高。
# GOFMT 规范编码
如果我们执行下 gofmt -w t.go
之后,代码就会优化不少
- 有些 IDE 会在保存时自动 gofmt,如果没有手动运行下命令
gofmt -w .
,可以将当前目录和子目录下的所有文件都格式化一遍,也很容易的是不是; - gofmt 不识别空行,因为空行是有意义的,因为空行有意义所以 gofmt 不知道如何处理,而这正是很多同学经常犯的问题;
- gofmt 有时候会因为对齐问题,导致额外的不必要的修改,这不会有什么问题,但是会干扰 CR 从而影响 CR 的质量。
先看空行问题,不能随便使用空行,因为空行有意义。不能在不该空行的地方用空行,不能在该有空行的地方不用空行,比如下面的例子:
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Println("show file err %v", err)
os.Exit(-1)
}
defer f.Close()
io.Copy(os.Stdout, f)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
上面的例子看起来就相当的奇葩,if
和 os.Open
之间没有任何原因需要个空行,结果来了个空行;而 defer
和 io.Copy
之间应该有个空行却没有个空行。空行是非常好的体现了逻辑关联的方式,所以空行不能随意,非常严重地影响可读性,要么就是一坨东西看得很费劲,要么就是突然看到两个紧密的逻辑身首异处,真的让人很诧异。上面的代码可以改成这样,是不是看起来很舒服了:
package main
import (
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Println("show file err %v", err)
os.Exit(-1)
}
defer f.Close()
io.Copy(os.Stdout, f)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
再看 gofmt 的对齐问题,一般出现在一些结构体有长短不一的字段,比如统计信息,比如下面的代码:
package main
type NetworkStat struct {
IncomingBytes int `json:"ib"`
OutgoingBytes int `json:"ob"`
}
func main() {
}
2
3
4
5
6
7
8
9
如果新增字段比较长,会导致之前的字段也会增加空白对齐,看起来整个结构体都改变了:
package main
type NetworkStat struct {
IncomingBytes int `json:"ib"`
OutgoingBytes int `json:"ob"`
IncomingPacketsPerHour int `json:"ipp"`
DropKiloRateLastMinute int `json:"dkrlm"`
}
func main() {
}
2
3
4
5
6
7
8
9
10
11
# Profile 性能调优
性能调优是一个工程问题,关键是测量后优化,而不是盲目优化。Go 提供了大量的测量程序的工具和机制,包括 Profiling Go Programs
(opens new window), Introducing HTTP Tracing
(opens new window),我们也在性能优化时使用过 Go 的 Profiling,原生支持是非常便捷的。
对于多线程同步可能出现的死锁和竞争问题,Go 提供了一系列工具链,比如 Introducing the Go Race Detector
, Data Race Detector
,不过打开 race 后有明显的性能损耗,不应该在负载较高的线上服务器打开,会造成明显的性能瓶颈。
推荐服务器开启 http profiling,侦听在本机可以避免安全问题,需要 profiling 时去机器上把 profile 数据拿到后,拿到线下分析原因。实例代码如下:
package main
import (
"net/http"
_ "net/http/pprof"
"time"
)
func main() {
go http.ListenAndServe("127.0.0.1:6060", nil)
for {
b := make([]byte, 4096)
for i := 0; i < len(b); i++ {
b[i] = b[i] + 0xf
}
time.Sleep(time.Nanosecond)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
编译成二进制后启动 go mod init private.me && go build . && ./private.me
,在浏览器访问页面可以看到各种性能数据的导航:http://localhost:6060/debug/pprof/
例如分析 CPU 的性能瓶颈,可以执行 go tool pprof private.me http://localhost:6060/debug/pprof/profile
,默认是分析 30 秒内的性能数据,进入 pprof 后执行 top 可以看到 CPU 使用最高的函数:
(pprof) top
Showing nodes accounting for 42.41s, 99.14% of 42.78s total
Dropped 27 nodes (cum <= 0.21s)
Showing top 10 nodes out of 22
flat flat% sum% cum cum%
27.20s 63.58% 63.58% 27.20s 63.58% runtime.pthread_cond_signal
13.07s 30.55% 94.13% 13.08s 30.58% runtime.pthread_cond_wait
1.93s 4.51% 98.64% 1.93s 4.51% runtime.usleep
0.15s 0.35% 98.99% 0.22s 0.51% main.main
2
3
4
5
6
7
8
9
除了 top,还可以输入 web 命令看调用图,还可以用 go-torch 看火焰图等。
# UTest 和 Coverage
当然工程化少不了 UTest 和覆盖率,关于覆盖 Go 也提供了原生支持 The cover story
(opens new window),一般会有专门的 CISE 集成测试环境。集成测试之所以重要,是因为随着代码规模的增长,有效的覆盖能显著的降低引入问题的可能性。
什么是有效的覆盖?一般多少覆盖率比较合适?80% 覆盖够好了吗?90% 覆盖一定比 30% 覆盖好吗?我觉得可不一定,参考 Testivus On Test Coverage (opens new window)。对于 UTest 和覆盖,我觉得重点在于:
- UTest 和覆盖率一定要有,哪怕是 0.1% 也必须要有,为什么呢?因为出现故障时让老板心里好受点啊,能用数据衡量出来裸奔的代码有多少;
- 核心代码和业务代码一定要分离,强调核心代码的覆盖率才有意义,比如整体覆盖了 80%,核心代码占 5%,核心代码覆盖率为 10%,那么这个覆盖就不怎么有效了;
- 除了关键正常逻辑,更应该重视异常逻辑,异常逻辑一般不会执行到,而一旦藏有 bug 可能就会造成问题。有可能有些罕见的代码无法覆盖到,那么这部分逻辑代码,CR 时需要特别人工 Review。
分离核心代码是关键。
可以将核心代码分离到单独的 package,对这个 package 要求更高的覆盖率,比如我们要求 98% 的覆盖(实际上做到了 99.14% 的覆盖)。对于应用的代码,具备可测性是非常关键的,举个我自己的例子,go-oryx 这部分代码是判断哪些 url 是代理,就不具备可测性,下面是主要的逻辑:
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if o := r.Header.Get("Origin"); len(o) > 0 {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
if proxyUrls == nil {
......
fs.ServeHTTP(w, r)
return
}
for _, proxyUrl := range proxyUrls {
srcPath, proxyPath := r.URL.Path, proxyUrl.Path
......
if proxy, ok := proxies[proxyUrl.Path]; ok {
p.ServeHTTP(w, r)
return
}
}
fs.ServeHTTP(w, r)
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
可以看得出来,关键需要测试的核心代码,在于后面如何判断URL符合定义的规范,这部分应该被定义成函数,这样就可以单独测试了:
func shouldProxyURL(srcPath, proxyPath string) bool {
if !strings.HasSuffix(srcPath, "/") {
// /api to /api/
// /api.js to /api.js/
// /api/100 to /api/100/
srcPath += "/"
}
if !strings.HasSuffix(proxyPath, "/") {
// /api/ to /api/
// to match /api/ or /api/100
// and not match /api.js/
proxyPath += "/"
}
return strings.HasPrefix(srcPath, proxyPath)
}
func run(ctx context.Context) error {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
......
for _, proxyUrl := range proxyUrls {
if !shouldProxyURL(r.URL.Path, proxyUrl.Path) {
continue
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Note: 可见,单元测试和覆盖率,并不是测试的事情,而是代码本身应该提高的代码“可测试性”。
另外,对于 Go 的测试还有几点值得说明:
- helper:测试时如果调用某个函数,出错时总是打印那个共用的函数的行数,而不是测试的函数。比如 test_helper.go (opens new window),如果
compare
不调用t.Helper()
,那么错误显示是hello_test.go:26: Returned: [Hello, world!], Expected: [BROKEN!]
,调用t.Helper()
之后是hello_test.go:18: Returned: [Hello, world!], Expected: [BROKEN!]
,实际上应该是 18 行的 case 有问题,而不是 26 行这个 compare 函数的问题; - benchmark:测试时还可以带 Benchmark 的,参数不是
testing.T
而是testing.B
,执行时会动态调整一些参数,比如 testing.B.N,还有并行执行的testing.PB. RunParallel
,参考 Benchamrk (opens new window); - main: 测试也是有个 main 函数的,参考 TestMain (opens new window),可以做一些全局的初始化和处理。
- doc.go: 整个包的文档描述,一般是在
package http
前面加说明,比如 http doc (opens new window) 的使用例子。
对于 Helper 还有一种思路,就是用带堆栈的 error,参考前面关于 errors 的说明,不仅能将所有堆栈的行数给出来,而且可以带上每一层的信息。
注意如果 package 只暴露了 interface,比如 go-oryx-lib: aac 通过
NewADTS() (ADTS, error)
返回的是接口ADTS
,无法给 ADTS 的函数加 Example;因此我们专门暴露了一个ADTSImpl
的结构体,而 New 函数返回的还是接口,这种做法不是最好的,让用户有点无所适从,不知道该用ADTS
还是ADTSImpl
。所以一种可选的办法,就是在包里面有个doc.go
放说明,例如net/http/doc.go
文件,就是在package http
前面加说明,比如 http doc 的使用例子。
# 注释和 Example
注释和 Example 是非常容易被忽视的,我觉得应该注意的地方包括:
- 项目的 README.md 和 Wiki,这实际上就是新人指南,因为新人如果能懂那么就很容易了解这个项目的大概情况,很多项目都没有这个。如果没有 README,那么就需要看文件,该看哪个文件?这就让人很抓狂了;
- 关键代码没有注释,比如库的 API,关键的函数,不好懂的代码段落。如果看标准库,绝大部分可以调用的 API 都有很好的注释,没有注释怎么调用呢?只能看代码实现了,如果每次调用都要看一遍实现,真的很难受了;
- 库没有 Example,库是一种要求很高的包,就是给别人使用的包,比如标准库。绝大部分的标准库的包,都有 Example,因为没有 Example 很难设计出合理的 API。
先看关键代码的注释,有些注释完全是代码的重复,没有任何存在的意义,唯一的存在就是提高代码的“注释率”,这又有什么用呢,比如下面代码:
wsconn *Conn //ws connection
// The RPC call.
type rpcCall struct {
// Setup logger.
if err := SetupLogger(......); err != nil {
// Wait for os signal
server.WaitForSignals(
2
3
4
5
6
7
8
9
10
如果注释能通过函数名看出来(比较好的函数名要能看出来它的职责),那么就不需要写重复的注释,注释要说明一些从代码中看不出来的东西,比如标准库的函数的注释:
// Serve accepts incoming connections on the Listener l, creating a
// new service goroutine for each. The service goroutines read requests and
// then call srv.Handler to reply to them.
//
// HTTP/2 support is only enabled if the Listener returns *tls.Conn
// connections and they were configured with "h2" in the TLS
// Config.NextProtos.
//
// Serve always returns a non-nil error and closes l.
// After Shutdown or Close, the returned error is ErrServerClosed.
func (srv *Server) Serve(l net.Listener) error {
// ParseInt interprets a string s in the given base (0, 2 to 36) and
// bit size (0 to 64) and returns the corresponding value i.
//
// If base == 0, the base is implied by the string's prefix:
// base 2 for "0b", base 8 for "0" or "0o", base 16 for "0x",
// and base 10 otherwise. Also, for base == 0 only, underscore
// characters are permitted per the Go integer literal syntax.
// If base is below 0, is 1, or is above 36, an error is returned.
//
// The bitSize argument specifies the integer type
// that the result must fit into. Bit sizes 0, 8, 16, 32, and 64
// correspond to int, int8, int16, int32, and int64.
// If bitSize is below 0 or above 64, an error is returned.
//
// The errors that ParseInt returns have concrete type *NumError
// and include err.Num = s. If s is empty or contains invalid
// digits, err.Err = ErrSyntax and the returned value is 0;
// if the value corresponding to s cannot be represented by a
// signed integer of the given size, err.Err = ErrRange and the
// returned value is the maximum magnitude integer of the
// appropriate bitSize and sign.
func ParseInt(s string, base int, bitSize int) (i int64, err error) {
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
标准库做得很好的是,会把参数名称写到注释中(而不是用 @param 这种方式),而且会说明大量的背景信息,这些信息是从函数名和参数看不到的重要信息。
咱们再看 Example,一种特殊的 test,可能不会执行,它的主要作用是为了推演接口是否合理,当然也就提供了如何使用库的例子,这就要求 Example 必须覆盖到库的主要使用场景。举个例子,有个库需要方式 SSRF 攻击,也就是检查 HTTP Redirect 时的 URL 规则,最初我们是这样提供这个库的:
func NewHttpClientNoRedirect() *http.Client {
看起来也没有问题,提供一种特殊的 http.Client,如果发现有 Redirect 就返回错误,那么它的 Example 就会是这样:
func ExampleNoRedirectClient() {
url := "http://xxx/yyy"
client := ssrf.NewHttpClientNoRedirect()
Req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Println("failed to create request")
return
}
resp, err := client.Do(Req)
fmt.Printf("status :%v", resp.Status)
}
2
3
4
5
6
7
8
9
10
11
12
13
这时候就会出现问题,我们总是返回了一个新的 http.Client,如果用户自己有了自己定义的 http.Client 怎么办?实际上我们只是设置了 http.Client.CheckRedirect 这个回调函数。如果我们先写 Example,更好的 Example 会是这样:
func ExampleNoRedirectClient() {
client := http.Client{}
//Must specify checkRedirect attribute to NewFuncNoRedirect
client.CheckRedirect = ssrf.NewFuncNoRedirect()
Req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Println("failed to create request")
return
}
resp, err := client.Do(Req)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 其他工程化
最近得知 WebRTC 有 4GB 的代码,包括它自己的以及依赖的代码,就算去掉一般的测试文件和文档,也有 2GB 的代码!!!编译起来真的是非常耗时间,而 Go 对于编译速度的优化,据说是在 Google 有过验证的,具体我们还没有到这个规模。具体可以参考 Why so fast? (opens new window),主要是编译器本身比 GCC 快 (5X),以及 Go 的依赖管理做的比较好。
Go 的内存和异常处理也做得很好,比如不会出现野指针,虽然有空指针问题可以用 recover 来隔离异常的影响。而 C 或 C++ 服务器,目前还没有见过没有内存问题的,上线后就是各种的野指针满天飞,总有因为野指针搞死的时候,只是或多或少罢了。
按照 Go 的版本发布节奏,6 个月就发一个版本,基本上这么多版本都很稳定,Go1.11 的代码一共有 166 万行 Go 代码,还有 12 万行汇编代码,其中单元测试代码有 32 万行(占 17.9%),使用实例 Example 有 1.3 万行。Go 对于核心 API 是全部覆盖的,提交有没有导致 API 不符合要求都有单元测试保证,Go 有多个集成测试环境,每个平台是否测试通过也能看到,这一整套机制让 Go 项目虽然越来越庞大,但是整体研发效率却很高。
# Go2 Transition
Go2 的设计草案在 Go 2 Draft Designs (opens new window) ,而 Go1 如何迁移到 Go2 也是我个人特别关心的问题,Python2 和 Python3 的那种不兼容的迁移方式简直就是噩梦一样的记忆。Go 的提案中,有一个专门说了迁移的问题,参考 Go2 Transition (opens new window)。
Go2 Transition 还不是最终方案,不过它也对比了各种语言的迁移,还是很有意思的一个总结。这个提案描述了在非兼容性变更时,如何给开发者挖的坑最小。
# GC
GC 一般是 C/C 程序员对于 Go 最常见、也是最先想到的一个质疑,GC 这玩意儿能行吗?我们以前 C/C 程序都是自己实现内存池的,我们内存分配算法非常牛逼的。
Go 的 GC 优化之路,可以详细读 Getting to Go: The Journey of Go's Garbage Collector
(opens new window)。
2014 年 Go1.4,GC 还是很弱的,是决定 Go 生死的大短板。
Go1.4 的 STW(Stop the World) Pause time 是 300 毫秒,而 Go1.5 优化到了 30 毫秒。
而 Go1.6 的 GC 暂停时间降低到了 3 毫秒左右。
Go1.8 则降低到了 0.5 毫秒左右,也就是 500 微秒。从 Go1.4 到 Go1.8,优化了 600 倍性能。
# Declaration Syntax
关于 Go 的声明语法 Go Declaration Syntax
(opens new window),和 C 语言有对比,在 The "Clockwise/Spiral Rule"
(opens new window) 这个文章中也详细描述了 C 的顺时针语法规则。其中有个例子:
int (*signal(int, void (*fp)(int)))(int);
这是个什么呢?翻译成 Go 语言就能看得很清楚:
func signal(a int, b func(int)) func(int)int
signal 是个函数,有两个参数,返回了一个函数指针。signal 的第一个参数是 int,第二个参数是一个函数指针。
当然实际上 C 语言如果借助 typedef 也是能获得比较好的可读性的:
typedef void (*PFP)(int);
typedef int (*PRET)(int);
PRET signal(int a, PFP b);
2
3
只是从语言的语法设计上来说,还是 Go 的可读性确实会好一些。这些点点滴滴的小傲娇,是否可以支撑我们够浪程序员浪起来的资本呢?至少 Rob Pike 不是拍脑袋和大腿想出来的规则嘛,这种认真和严谨是值得佩服和学习的。