Go 开发关键技术指南 | 敢问路在何方?(内含超全知识大图)

点击这里,查看超全知识大图及其他工程化等重要内容

Engineering

我觉得 Go 在工程上良好的支持,是 Go 能够在服务器领域有一席之地的重要原因。这里说的工程友好包括:

  • gofmt 保证代码的基本一致,增加可读性,避免在争论不清楚的地方争论;
  • 原生支持的 profiling,为性能调优和死锁问题提供了强大的工具支持;
  • utest 和 coverage,持续集成,为项目的质量提供了良好的支撑;
  • example 和注释,让接口定义更友好合理,让库的质量更高。

GOFMT 规范编码

之前有段时间,朋友圈霸屏的新闻是码农因为代码不规范问题枪击同事,虽然实际上枪击案可能不是因为代码规范,但可以看出大家对于代码规范问题能引发枪击是毫不怀疑的。这些年在不同的公司码代码,和不同的人一起码代码,每个地方总有人喜欢纠结于 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 只能解决基本的代码风格问题,虽然这个已经节约了不少口舌和唾沫,我想特别强调几点:

  • 有些 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)
}

上面的例子看起来就相当的奇葩,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() {
}

比较好的解决办法就是用注释,添加注释后就不会强制对齐了。

Profile 性能调优

性能调优是一个工程问题,关键是测量后优化,而不是盲目优化。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 和 Coverage

当然工程化少不了 UTest 和覆盖率,关于覆盖 Go 也提供了原生支持 The cover story,一般会有专门的 CISE 集成测试环境。集成测试之所以重要,是因为随着代码规模的增长,有效的覆盖能显著的降低引入问题的可能性。

什么是有效的覆盖?一般多少覆盖率比较合适?80% 覆盖够好了吗?90% 覆盖一定比 30% 覆盖好吗?我觉得可不一定,参考 Testivus On Test Coverage。对于 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)
    })

可以看得出来,关键需要测试的核心代码,在于后面如何判断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 的测试还有几点值得说明:

  • helper:测试时如果调用某个函数,出错时总是打印那个共用的函数的行数,而不是测试的函数。比如 test_helper.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 函数的问题;
  • benchmark:测试时还可以带 Benchmark 的,参数不是 testing.T 而是 testing.B,执行时会动态调整一些参数,比如 testing.B.N,还有并行执行的 testing.PB. RunParallel,参考 Benchamrk
  • main: 测试也是有个 main 函数的,参考 TestMain,可以做一些全局的初始化和处理。
  • doc.go: 整个包的文档描述,一般是在 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

注释和 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(

如果注释能通过函数名看出来(比较好的函数名要能看出来它的职责),那么就不需要写重复的注释,注释要说明一些从代码中看不出来的东西,比如标准库的函数的注释:

// 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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值