天天看點

Go 開發關鍵技術指南 | 敢問路在何方?(内含超全知識大圖)Go 開發指南大圖EngineeringGo2 TransitionOthers雲原生技術公開課

作者 | 楊成立(忘籬) 阿裡巴巴進階技術專家

Go 開發關鍵技術指南文章目錄:

Go 開發指南大圖

Go 開發關鍵技術指南 | 敢問路在何方?(内含超全知識大圖)Go 開發指南大圖EngineeringGo2 TransitionOthers雲原生技術公開課

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

那麼我們自然知道應該如何提供接口了。

其他工程化

最近得知 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 項目雖然越來越龐大,但是整體研發效率卻很高。

Go2 Transition

Go2 的設計草案在 

Go 2 Draft Designs

 ,而 Go1 如何遷移到 Go2 也是我個人特别關心的問題,Python2 和 Python3 的那種不相容的遷移方式簡直就是噩夢一樣的記憶。Go 的提案中,有一個專門說了遷移的問題,參考 

Go2 Transition 還不是最終方案,不過它也對比了各種語言的遷移,還是很有意思的一個總結。這個提案描述了在非相容性變更時,如何給開發者挖的坑最小。

目前 Go1 的标準庫是遵守相容性原則的,參考 Go 1 compatibility guarantee,這個規範保證了 Go1 沒有相容性問題,幾乎可以沒有影響的更新比如從 Go1.2 更新到 Go1.11。

幾乎

的意思,是很大機率是沒有問題,當然如果用了一些非常冷門的特性,可能會有坑,我們遇到過 json 解析時,内嵌結構體的資料成員也得是 exposed 的才行,而這個在老版本中是可以非 exposed;還遇到過 cgo 對于連結參數的變更導緻編譯失敗,這些問題幾乎很難遇到,都可以算是相容的吧,有時候隻是把模糊不清的定義清楚了而已。

Go2 在語言和标準庫上,會打破 Go1 的相容性規範,也就是和 Go1 不再相容。不過 Go 是分布式開源社群在維護,不能依賴于 

flag day

,還是要容許不同 Go 版本寫的 package 的互操作性。

先了解下各個語言如何考慮相容性:

  • C 是嚴格向後相容的,很早寫的程式總是能在新的編譯器中編譯。另外新的編譯器也支援指定之前的标準,比如 

    -std=c90

     使用 

    ISO C90

     标準編譯程式。關鍵的特性是編譯成目标檔案後,不同版本的 C 的目标檔案,能完美的連結成執行程式;C90 實際上是對之前 

    K&R C

     版本不相容的,主要引入了 

    volatile

     關鍵字、整數精度問題,還引入了  trigraphs ,最糟糕的是引入了  undefined  行為比如數組越界和整數溢出的行為未定義。從 C 上可以學到的是:後向相容非常重要;非常小的打破相容性也問題不大特别是可以通過編譯器選項來處理;能将不同版本的目标檔案連結到一起是非常關鍵的;undefined 行為嚴重困擾開發者容易造成問題;
  • C++ 也是 ISO 組織驅動的語言,和 C 一樣也是向後相容的。C++和C一樣坑爹的地方坑到吐血,比如 undefined行為等。盡管一直保持向後相容,但是新的C++代碼比如C++11 看起來完全不同,這是因為有新的改變的特性,比如很少會用裸指針、比如 range 代替了傳統的 for 循環,這導緻熟悉老C++文法的程式員看新的代碼非常難受甚至看不懂。C++毋庸置疑是非常流行的,但是新的語言标準在這方面沒有貢獻。從C++上可以學到的新東西是:盡管保持向後相容,語言的新版本可能也會帶來巨大的不同的感受(保持向後相容并不能保證能持續看懂)。
  • Java 也是向後相容的,是在位元組碼層面和語言層面都向後相容,盡管語言上不斷新增了關鍵字。Java 的标準庫非常龐大,也不斷在更新,過時的特性會被标記為 deprecated 并且編譯時會有警告,理論上一定版本後 deprecated 的特性會不可用。Java 的相容性問題主要在 JVM 解決,如果用新的版本編譯的位元組碼,得用新的 JVM 才能執行。Java 還做了一些前向相容,這個影響了位元組碼啥的(我本身不懂 Java,作者也不說自己不是專家,我就沒仔細看了)。Java 上可以學到的新東西是:要警惕因為保持相容性而限制語言未來的改變。
  • Python2.7 是 2010 年釋出的,目前主要是用這個版本。Python3 是 2006 年開始開發,2008 年釋出,十年後的今天還沒有遷移完成,甚至主要是用的 Python2 而不是 Python3,這當然不是 Go2 要走的路。看起來是因為缺乏向後相容導緻的問題,Python3 刻意的和之前版本不相容,比如 print 從語句變成了一個函數,string 也變成了 Unicode(這導緻和 C 調用時會有很多問題)。沒有向後相容,同時還是解釋型語言,這導緻 Python2 和 3 的代碼混着用是不可能的,這意味着程式依賴的所有庫必須支援兩個版本。Python 支援 

    from __future__ import FEATURE

    ,這樣可以在 Python2 中用 Python3 的特性。Python 上可以學到的東西是:向後相容是生死攸關的;和其他語言互操作的接口相容是非常重要的;能否更新到新的語言是由調用的庫支援的。
  • Perl6 是 2000 年開始開發的,15 年後才正式釋出,這也不是 Go2 應該走的路。這麼漫長的主要原因包括:刻意沒有向後相容,隻有語言的規範沒有實作而這些規範不斷的修改。Perl 上可以學到的東西是:不要學 Perl;設定期限按期傳遞;别一下子全部改了。

特别說明的是,非常高興的是 Go2 不會重新走 Python3 的老路子,當初被 Python 的版本相容問題坑得不要不要的。

雖然上面隻是列舉了各種語言的演進,确實也了解得更多了,有時候描述問題本身,反而更能明白解決方案。C 和 C 的向後相容确實非常關鍵,但也不是它們能有今天地位的原因,C11 的新特性到底增加了多少 DAU 呢,确實是值得思考的。另外 C11 加了那麼多新的語言特性,比如 WebRTC 代碼就是這樣,很多老 C 程式員看到後一臉懵逼,和一門新的語言一樣了,是否保持完全的相容不能做一點點變更,其實也不是的。

應該将 Go 的語言版本和标準庫的版本分開考慮,這兩個也是分别演進的,例如 alias 是 1.9 引入的向後相容的特性,1.9 之前的版本不支援,1.9 之後的都支援。語言方面包括:

  • Language additions 新增的特性。比如 1.9 新增的 type alias,這些向後相容的新特性,并不要求代碼中指定特殊的版本号,比如用了 alias 的代碼不用指定要 1.9 才能編譯,用之前的版本會報錯。向後相容的語言新增的特性,是依靠程式員而不是工具鍊來維護的,要用這個特性或庫更新到要求的版本就可以。
  • Language removals 删除的特性。比如有個提案  #3939  去掉 

    string(int)

    ,字元串構造函數不支援整數,假設這個在 Go1.20 版本去掉,那麼 Go1.20 之後這種 

    string(1000)

     代碼就要編譯失敗了。這種情況沒有特别好的辦法能解決,我們可以提供工具,将代碼自動替換成新的方式,這樣就算庫維護者不更新,使用者自己也能更新。這種場景引出了指定最大版本,類似 C 的 

    -std=C90

    ,可以指定最大編譯的版本比如 

    -lang=go1.19

    ,當然必須能和 Go1.20 的代碼連結。指定最大版本可以在 go.mod 中指定,這需要工具鍊相容曆史的版本,由于這種特性的删除不會很頻繁,維護負擔還是可以接受的。
  • Minimum language version 最小要求版本。為了可以更明确的錯誤資訊,可以允許子產品在 

    go.mod

     中指定最小要求的版本,這不是強制性的,隻是說明了這個資訊後編譯工具能明确給出錯誤,比如給出應該用具體哪個版本。
  • Language redefinitions 語言重定義。比如 Go1.1 時,int 在 64 位系統中長度從 4 位元組變成了 8 位元組,這會導緻很多潛在的問題。比如  #20733  修改了變量在 for 中的作用域,看起來是解決潛在的問題,但也可能會引入問題。引入關鍵字一般不會有問題,不過如果和函數沖突就會有問題,比如 error: check。為了讓 Go 的生态能遷移到 Go2,語言重定義的事情應該盡量少做,因為我們不再能依賴編譯器檢查錯誤。雖然指定版本能解決這種問題,但是這始終會導緻未知的結果,很有可能一更新 Go 版本就挂了。我覺得對于語言重定義,應該完全禁止。比如 #20733 可以改成禁止這種做法,這樣就會變成編譯錯誤,可能會幫助找到代碼中潛在的 BUG。
  • Build tags 編譯 tags。在指定檔案中指定編譯選項,是現有的機制,不過是指定的 release 版本号,它更多是指定了最小要求的版本,而沒有解決最大依賴版本問題。
  • Import go2 導入新特性。和 Python 的特性一樣,可以在 Go1 中導入 Go2 的新特性,比如可以顯式地導入 

    import "go2/type-aliases"

    ,而不是在 go.mod 中隐式的指定。這會導緻語言比較複雜,将語言打亂成了各種特性的組合。而且這種方式一旦使用,将無法去掉。這種方式看起來不太适合 Go。

如果有更多的資源來維護和測試,标準庫後續會更快釋出,雖然還是 6 個月的周期。标準庫方面的變更包括:

  • Core standard library 核心标準庫。有些和編譯工具鍊相關的庫,還有其他的一些關鍵的庫,應該遵守 6 個月的釋出周期,而且這些核心标準庫應該保持 Go1 的相容性,比如 

    os/signal

    reflect

    runtime

    sync

    testing

    time

    unsafe

     等等。我可能樂觀的估計 

    net

    os

    , 和 

    syscall

     不在這個範疇。
  • Penumbra standard library 邊緣标準庫。它們被獨立維護,但是在一個 release 中一起釋出,目前核心庫大部分都屬于這種。這使得可以用 

    go get

     等工具來更新這些庫,比 6 個月的周期會更快。标準庫會保持和前面版本的編譯相容,至少和前面一個版本相容。
  • Removing packages from the standard library 去掉一些不太常用的标準庫,比如 

    net/http/cgi

     等。

如果上述的工作做得很好的話,開發者會感覺不到有個大版本叫做 Go2,或者這種緩慢而自然的變化逐漸全部更新成了 Go2。甚至我們都不用宣傳有個 Go2,既然沒有 C2.0 為何要 Go2.0 呢?主流的語言比如 C、C++ 和 Java 從來沒有 2.0,一直都是 1.N 的版本,我們也可以模仿它們。事實上,一般所認為的全新的 2.0 版本,若出現不相容性的語言和标準庫,對使用者也不是個好結果,甚至還是有害的。

Others

關于 Go,還有哪些重要的技術值得了解呢?下面将進行詳細的分享。

GC

GC 一般是 C/C 程式員對于 Go 最常見、也是最先想到的一個質疑,GC 這玩意兒能行嗎?我們以前 C/C 程式都是自己實作記憶體池的,我們記憶體配置設定算法非常牛逼的。

Go 的 GC 優化之路,可以詳細讀 

Getting to Go: The Journey of Go's Garbage Collector

2014 年 Go1.4,GC 還是很弱的,是決定 Go 生死的大短闆。

Go 開發關鍵技術指南 | 敢問路在何方?(内含超全知識大圖)Go 開發指南大圖EngineeringGo2 TransitionOthers雲原生技術公開課

上圖是 Twitter 的線上服務監控。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 倍性能。

如何看 GC 的 STW 時間呢?可以引入 

net/http/pprof

 這個庫,然後通過 curl 來擷取資料,執行個體代碼如下:

package main

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    http.ListenAndServe("localhost:6060", nil)
}           

啟動程式後,執行指令就可以拿到結果(由于上面的例子中沒有 GC,下面的資料取的是另外程式的部分資料):

$ curl 'http://localhost:6060/debug/pprof/allocs?debug=1' 2>/dev/null |grep PauseNs
# PauseNs = [205683 79032 202102 82216 104853 142320 90058 113638 152504 
145965 72047 49690 158458 60499 99610 112754 122262 52252 49234 68420 159857 
97940 226085 103644 135428 245291 141997 92470 79974 132817 74634 65653 73582 
47399 51653 86107 48619 62583 68906 131868 111903 85482 44531 74585 50162 
31445 107397 10903081771 92603 58585 96620 40416 29763 102248 32804 49394 
83715 77099 108983 66133 47832 35379 143949 69235 27820 35677 99430 104303 
132657 63542 39434 126418 63845 167969 116438 68904 77899 136506 119708 47501]           

可以用 python 計算最大值是 322 微秒,最小是 26 微秒,平均值是 81 微秒。

Declaration Syntax

關于 Go 的聲明文法 

Go Declaration Syntax

,和 C 語言有對比,在 

The "Clockwise/Spiral Rule"

 這個文章中也較長的描述了 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);           

隻是從語言的文法設計上來說,還是 Go 的可讀性确實會好一些。這些點點滴滴的小傲嬌,是否可以支撐我們夠浪程式員浪起來的資本呢?至少 Rob Pike 不是拍腦袋和大腿想出來的規則嘛,這種認真和嚴謹是值得佩服和學習的。

Documents

新的語言文檔支援都很好,不用買本書看,Go 也是一樣,Go 官網曆年比較重要的文章包括:

  • 文法特性及思考:

    Go Declaration Syntax

    The Laws of Reflection

    Constants

    Generics Discussion

    Another Go at Language Design

    Composition not inheritance

    Interfaces and other types

  • 并發相關特性:

    Share Memory By Communicating

    Go Concurrency Patterns: Timing out, moving on

    Concurrency is not parallelism

    Advanced Go Concurrency Patterns

    Go Concurrency Patterns: Pipelines and cancellation

    Go Concurrency Patterns: Context

    Mutex or Channel

  • 錯誤處理相關:

    Defer, Panic, and Recover

    Error handling and Go

    Errors are values

    Stack traces and the errors package

    Error Handling In Go

    The Error Model

  • 性能和優化:

    Profiling Go Programs

    Introducing the Go Race Detector

    The cover story

    Introducing HTTP Tracing

    Data Race Detector

  • 标準庫說明:

    Go maps in action

    Go Slices: usage and internals

    Arrays, slices (and strings): The mechanics of append

    Strings, bytes, runes and characters in Go

  • 和C的結合:

    C? Go? Cgo!

  • 項目相關:

    Organizing Go code

    Package names

    Effective Go

    versioning

    Russ Cox: vgo

  • 關于GC:

    Go GC: Prioritizing low latency and simplicity

    Getting to Go: The Journey of Go Garbage Collector

    Proposal: Eliminate STW stack re-scanning

其中,文章中有引用其他很好的文章,我也列出來哈:

SRS

 是使用 ST,單程序單線程,性能是 EDSM 模型的 

nginx-rtmp

 的 3 到 5 倍,參考 

SRS: Performance

,當然不是 ST 本身性能是 EDSM 的三倍,而是說 ST 并不會比 EDSM 性能低,主要還是要根據業務上的特征做優化。

關于 ST 和 EDSM,參考本文前面關于  Concurrency

 對于協程的描述,ST 它是 C 的一個協程庫,EDSM 是異步事件驅動模型。

SRS 是單程序單線程,可以擴充為多程序,可以在 SRS 中改代碼 Fork 子程序,或者使用一個 TCP 代理,比如 TCP 代理 

go-oryx: rtmplb

在 2016 年和 2017 年我用 Go 重寫過 SRS,驗證過 Go 使用 2CPU 可以跑到 C10K,參考 

go-oryx

v0.1.13 Supports 10k(2CPUs) for RTMP players

。由于僅僅是語言的差異而重寫一個項目,沒有找到更好的方式或理由,覺得很不值得,是以還是放棄了 Go 語言版本,隻維護 C++ 版本的 SRS。Go 目前一般在 API 伺服器用得比較多,能否在流媒體伺服器中應用?答案是肯定的,我已經實作過了。

後來在 2017 年,終于找到相對比較合理的方式來用 Go 寫流媒體,就是隻提供庫而不是二進制的伺服器,參考  go-oryx-lib

目前 Go 可以作為 SRS 前面的代理,實作多核的優勢,參考 

關注“阿裡巴巴雲原生”公衆号,回複 Go 即可擷取清晰知識大圖及最全腦圖連結!

作者簡介

楊成立(花名:忘籬),阿裡巴巴進階技術專家。他發起并維護了基于 MIT 協定的開源流媒體伺服器項目 - SRS(Simple Rtmp Server)。感興趣的同學可以掃描下方二維碼進入釘釘群,直面和大神進行交流!

Go 開發關鍵技術指南 | 敢問路在何方?(内含超全知識大圖)Go 開發指南大圖EngineeringGo2 TransitionOthers雲原生技術公開課

雲原生技術公開課

Go 開發關鍵技術指南 | 敢問路在何方?(内含超全知識大圖)Go 開發指南大圖EngineeringGo2 TransitionOthers雲原生技術公開課

本課程是由 CNCF 官方與阿裡巴巴強強聯合,共同推出的以“雲原生技術體系”為核心、以“技術解讀”和“實踐落地”并重的系列

技術公開課
阿裡巴巴雲原生 關注微服務、Serverless、容器、Service Mesh 等技術領域、聚焦雲原生流行技術趨勢、雲原生大規模的落地實踐,做最懂雲原生開發者的技術圈。”