点击这里,查看超全知识大图及其他工程化等重要内容
我觉得 Go 在工程上良好的支持,是 Go 能够在服务器领域有一席之地的重要原因。这里说的工程友好包括:
之前有段时间,朋友圈霸屏的新闻是码农因为代码不规范问题枪击同事,虽然实际上枪击案可能不是因为代码规范,但可以看出大家对于代码规范问题能引发枪击是毫不怀疑的。这些年在不同的公司码代码,和不同的人一起码代码,每个地方总有人喜欢纠结于 if ()
中是否应该有空格,甚至还大开怼戒。
Go 语言从来不会有这种争论,因为有 gofmt
,语言的工具链支持了格式化代码,避免大家在代码风格上白费口舌。
比如,下面的代码看着真是揪心,任何语言都可以写出类似的一坨代码:
package main
import (
"fmt"
"strings"
)
func foo()[]string {
return []string{"gofmt","pprof","cover"}}
func main() {
if v:=foo();len(v)>0{fmt.Println("Hello",strings.Join(v,", "))}
}
如果有几万行代码都是这样,是不是有扣动扳机的冲动?如果我们执行下 gofmt -w t.go
之后,就变成下面的样子:
package main
import (
"fmt"
"strings"
)
func foo() []string {
return []string{"gofmt", "pprof", "cover"}
}
func main() {
if v := foo(); len(v) > 0 {
fmt.Println("Hello", strings.Join(v, ", "))
}
}
是不是心情舒服多了?gofmt 只能解决基本的代码风格问题,虽然这个已经节约了不少口舌和唾沫,我想特别强调几点:
gofmt -w .
,可以将当前目录和子目录下的所有文件都格式化一遍,也很容易的是不是;先看空行问题,不能随便使用空行,因为空行有意义。不能在不该空行的地方用空行,不能在该有空行的地方不用空行,比如下面的例子:
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)
}
上面的例子看起来就相当的奇葩,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)
}
再看 gofmt 的对齐问题,一般出现在一些结构体有长短不一的字段,比如统计信息,比如下面的代码:
package main
type NetworkStat struct {
IncomingBytes int `json:"ib"`
OutgoingBytes int `json:"ob"`
}
func main() {
}
如果新增字段比较长,会导致之前的字段也会增加空白对齐,看起来整个结构体都改变了:
package main
type NetworkStat struct {
IncomingBytes int `json:"ib"`
OutgoingBytes int `json:"ob"`
IncomingPacketsPerHour int `json:"ipp"`
DropKiloRateLastMinute int `json:"dkrlm"`
}
func main() {
}
比较好的解决办法就是用注释,添加注释后就不会强制对齐了。
性能调优是一个工程问题,关键是测量后优化,而不是盲目优化。Go 提供了大量的测量程序的工具和机制,包括 Profiling Go Programs
,Introducing HTTP Tracing
,我们也在性能优化时使用过 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)
}
}
编译成二进制后启动 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
除了 top,还可以输入 web 命令看调用图,还可以用 go-torch 看火焰图等。
当然工程化少不了 UTest 和覆盖率,关于覆盖 Go 也提供了原生支持 The cover story
,一般会有专门的 CISE 集成测试环境。集成测试之所以重要,是因为随着代码规模的增长,有效的覆盖能显著的降低引入问题的可能性。
什么是有效的覆盖?一般多少覆盖率比较合适?80% 覆盖够好了吗?90% 覆盖一定比 30% 覆盖好吗?我觉得可不一定,参考 Testivus On Test Coverage。对于 UTest 和覆盖,我觉得重点在于:
分离核心代码是关键。
可以将核心代码分离到单独的 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)
})
可以看得出来,关键需要测试的核心代码,在于后面如何判断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
}
代码参考 go-oryx: Extract and test URL proxy,覆盖率请看 gocover: For go-oryx coverage,这样的代码可测性就会比较好,也能在有限的精力下尽量让覆盖率有效。
Note: 可见,单元测试和覆盖率,并不是测试的事情,而是代码本身应该提高的代码“可测试性”。
另外,对于 Go 的测试还有几点值得说明:
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 函数的问题;testing.T
而是 testing.B
,执行时会动态调整一些参数,比如 testing.B.N,还有并行执行的 testing.PB. RunParallel
,参考 Benchamrk;package http
前面加说明,比如 http doc 的使用例子。对于 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 是非常容易被忽视的,我觉得应该注意的地方包括:
先看关键代码的注释,有些注释完全是代码的重复,没有任何存在的意义,唯一的存在就是提高代码的“注释率”,这又有什么用呢,比如下面代码:
wsconn *Conn //ws connection
// The RPC call.
type rpcCall struct {
// Setup logger.
if err := SetupLogger(......); err != nil {
// Wait for os signal
server.WaitForSignals(
如果注释能通过函数名看出来(比较好的函数名要能看出来它的职责),那么就不需要写重复的注释,注释要说明一些从代码中看不出来的东西,比如标准库的函数的注释:
// 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) {
标准库做得很好的是,会把参数名称写到注释中(而不是用 @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)
}
这时候就会出现问题,我们总是返回了一个新的 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)
}
那么我们自然知道应该如何提供接口了。
关键字:JSON Cloud Native Java 程序员 编译器 Go API C++ 数据格式 Python