1. 程式人生 > >Go 開發關鍵技術指南 | 敢問路在何方?(內含超全知識大圖)

Go 開發關鍵技術指南 | 敢問路在何方?(內含超全知識大圖)


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

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

  • 為什麼你要選擇 Go?
  • Go 面向失敗程式設計
  • 帶著伺服器程式設計金剛經走進 2020 年
  • 敢問路在何方?

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

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

其他工程化

最近得知 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。

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/signalreflectruntimesynctestingtimeunsafe 等等。我可能樂觀的估計 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 生死的大短板。

上圖是 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

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

  • Go Declaration Syntax,引用了一篇神作,介紹 C 的螺旋語法,寫 C 的多,讀過這個的不多,The "Clockwise/Spiral Rule"
  • Strings, bytes, runes and characters in Go,引用了很好的一篇文章,號稱每個人都要懂的,關於字符集和 Unicode 的文章,Every Software Developer Must Know (No Excuses!)
  • 為何錯誤碼模型,比異常模型更有優勢,參考 Cleaner, more elegant, and wrong 以及 Cleaner, more elegant, and harder to recognize。
  • Go 中的面向物件設計原則 SOLID。
  • Go 的版本語義 Semantic Versioning,如何在大型專案中規範版本,避免導致依賴地獄 (Dependency Hell) 問題。

SRS

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-oryx。

關注“阿里巴巴雲原生”公眾號,回覆 Go 即可獲取清晰知識大圖及最全腦圖連結!

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

雲原生技術公開課





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

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