Go开发关键技术指南:Engineering

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)
}

上面的例子看起来就相当的奇葩,ifos.Open之间没有任何原因需要个空行,结果来了个空行;而deferio.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)
}

那么我们自然知道应该如何提供接口了。

其他工程化

最近得知WebRTC有4GB的代码,包括它自己的以及依赖的代码,就算去掉一般的测试文件和文档,也有2GB的代码!!!编译起来真的是非常的耗时间,而Go对于编译速度的优化,据说是在Google有过验证的,具体我们还没有到这个规模。具体可以参考Why so fast?,主要是编译器本身比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项目虽然越来越庞大,但是整体研发效率却很高。

Links

由于简书限制了文章字数,只好分成不同章节:

  • Overview 为何Go有时候也叫Golang?为何要选择Go作为服务器开发的语言?是冲动?还是骚动?Go的重要里程碑和事件,当年吹的那些牛逼,都实现了哪些?
  • Could Not Recover 君可知,有什么panic是无法recover的?包括超过系统线程限制,以及map的竞争写。当然一般都能recover,比如Slice越界、nil指针、除零、写关闭的chan等。
  • Errors 为什么Go2的草稿3个有2个是关于错误处理的?好的错误处理应该怎么做?错误和异常机制的差别是什么?错误处理和日志如何配合?
  • Logger 为什么标准库的Logger是完全不够用的?怎么做日志切割和轮转?怎么在混成一坨的服务器日志中找到某个连接的日志?甚至连接中的流的日志?怎么做到简洁又够用?
  • Interfaces 什么是面向对象的SOLID原则?为何Go更符合SOLID?为何接口组合比继承多态更具有正交性?Go类型系统如何做到looser, organic, decoupled, independent, and therefore scalable?一般软件中如果出现数学,要么真的牛逼要么装逼。正交性这个数学概念在Go中频繁出现,是神仙还是妖怪?为何接口设计要考虑正交性?
  • Modules 如何避免依赖地狱(Dependency Hell)?小小的版本号为何会带来大灾难?Go为什么推出了GOPATH、Vendor还要搞module和vgo?新建了16个仓库做测试,碰到了9个坑,搞清楚了gopath和vendor如何迁移,以及vgo with vendor如何使用(毕竟生产环境不能每次都去外网下载)。
  • Concurrency & Control 服务器中的并发处理难在哪里?为什么说Go并发处理优势占领了云计算开发语言市场?什么是C10K、C10M问题?如何管理goroutine的取消、超时和关联取消?为何Go1.7专门将context放到了标准库?context如何使用,以及问题在哪里?
  • Engineering Go在工程化上的优势是什么?为什么说Go是一门面向工程的语言?覆盖率要到多少比较合适?什么叫代码可测性?为什么良好的库必须先写Example?
  • Go2 Transition Go2会像Python3不兼容Python2那样作吗?C和C++的语言演进可以有什么不同的收获?Go2怎么思考语言升级的问题?
  • SRS & Others Go在流媒体服务器中的使用。Go的GC靠谱吗?Twitter说相当的靠谱,有图有真相。为何Go的声明语法是那样?C的又是怎样?是拍的大腿,还是拍的脑袋?
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容