1. 程式人生 > >讀生產環境下go語言最佳實踐有感

讀生產環境下go語言最佳實踐有感

最近看了一篇關於go產品開發最佳實踐的文章,go-in-procution。作者總結了他們在用go開發過程中的很多實際經驗,我們很多其實也用到了,鑑於此,這裡就簡單的寫寫讀後感,後續我也爭取能將這篇文章翻譯出來。後面我用soundcloud來指代原作者。

開發環境

在soundcloud,每個人使用一個獨立的GOPATH,並且在GOPATH直接按照go規定的程式碼路徑方式clone程式碼。

$ mkdir -p $GOPATH/src/github.com/soundcloud
$ cd $GOPATH/src/github.com/soundcloud
$ git clone [email protected]:soundcloud/roshi

對於go來說,通常的工程管理應該是如下的目錄結構:

proj/
    src/
        modulea/
            a.go
        moudleb/
            b.go
        app/
            main.go
    pkg/
    bin/

然後我們在GOPATH裡面將proj的路徑設定上去,這樣就可以進行編譯運行了。這本來沒啥,但是如果我們要將其程式碼提交到github,並允許另外的開發者使用,我們就不能將整個proj的東西提交上面,如果提交了,就很蛋疼了。外面的開發者可能這麼引用:

import "github.com/yourname/proj/src/modulea"

但是我們自己在程式碼裡面就可以直接:

import "github.com/yourname/proj/modulea"

如果外面的開發者需要按照去掉src的引用方式,只能把GOPATH設定到proj目錄,如果import的多了,會讓人崩潰的。

我曾今也被這事情給折騰了好久,終於再看了vitess的程式碼之後,發現了上面這種方式,覺得非常不錯。

工程目錄結構

如果一個專案中檔案數量不是很多,直接放在main包裡面就行了,不需要在拆分成多個包了,譬如:

github.com/soundcloud/simple/
    README.md
    Makefile
    main.go
    main_test.go
    support.go
    support_test.go

如果真的有公共的類庫,在拆分成單獨的包處理。

有時候,一個工程可能會包括多個二進位制應用。譬如,一個job可能需要一個server,一個worker或者一個janitor,在這種情況下,建立多個子目錄作為不同的main包,分別放置不同的二進位制應用。同時使用另外的子目錄實現公共的函式。

github.com/soundcloud/complex/
README.md
Makefile
complex-server/
    main.go
    main_test.go
    handlers.go
    handlers_test.go
complex-worker/
    main.go
    main_test.go
    process.go
    process_test.go
shared/
    foo.go
    foo_test.go
    bar.go
    bar_test.go

這點我的做法稍微有一點不一樣,主要是參考vitess,我喜歡建立一個總的cmd目錄,然後再在裡面設定不同的子目錄,這樣外面就不需要猜測這個目錄是庫還是應用。

程式碼風格

程式碼風格這沒啥好說的,直接使用gofmt解決,通常我們也約定gofmt的時候不帶任何其他引數。

最好將你的編輯器配置成儲存程式碼的時候自動進行gofmt處理。

Google最近釋出了go的程式碼規範,soundcloud做了一些改進:

  • 避免命名函式返回值,除非能明確的表明含義。
  • 儘量少用make和new,除非真有必要,或者預先知道需要分配的大小。
  • 使用struct{}作為標記值,而不是bool或者interface{}。譬如set我們就用map[string]struct{}來實現,而不是map[string]bool。

如果一個函式有多個引數,並且單行長度很長,需要拆分,最好不用java的方式:

// Don't do this.
func process(dst io.Writer, readTimeout,
    writeTimeout time.Duration, allowInvalid bool,
    max int, src <-chan util.Job) {
    // ...
}

而是使用:

func process(
    dst io.Writer,
    readTimeout, writeTimeout time.Duration,
    allowInvalid bool,
    max int,
    src <-chan util.Job,
) {
    // ...
}

類似的,當構造一個物件的時候,最好在初始化的時候就傳入相關引數,而不是在後面設定:

f := foo.New(foo.Config{    

    Site: "zombo.com",            

    Out:  os.Stdout,

    Dest: conference.KeyPair{

Key:   "gophercon",

        Value: 2014,

    },

})

// Don't do this. f := &Foo{} // or, even worse: new(Foo) f.Site = "zombo.com" f.Out = os.Stdout f.Dest.Key = "gophercon" f.Dest.Value = 2014

如果一些變數是後續通過其他操作才能獲取的,我覺得就可以在後續設定了。

配置

soundcloud使用go的flag包來進行配置引數的傳遞,而不是通過配置檔案或者環境變數。

flag的配置是在main函式裡面定義的,而不是在全域性範圍內。

func main() {
    var (
        payload = flag.String("payload", "abc", "payload data")
        delay   = flag.Duration("delay", 1*time.Second, "write delay")
    )
    flag.Parse()
    // ...
}

關於使用flag作為配置引數的傳遞,我持保留意見。如果一個應用需要特別多的配置引數,使用flag比較讓人蛋疼了。這時候,使用配置檔案反而比較好,我個人傾向於使用json作為配置,原因在這裡

日誌

soundcloud使用的是go的log日誌,他們也說明了他們的log並不需要太多的其他功能,譬如log分級等。對於log,我參考python的log寫了一個,在這裡。該log支援log級別,支援自定義loghandler。

soundcloud還提到了一個telemetry的概念,我真沒好的辦法進行翻譯,據我的瞭解可能就是程式的資訊收集,包括響應時間,QPS,記憶體執行錯誤等。

通常telemetry有兩種方式,推和拉。

推模式就是主動的將資訊傳送給特定的外部系統,而拉模式則是將其寫入到某一個地方,允許外部系統來獲取該資料。

這兩種方式都有不同的定位,如果需要及時,直觀的看到資料,推模式是一個很好的選擇,但是該模式可能會佔用過多的資源,尤其是在資料量大的情況下面,會很消耗CPU和頻寬。

soundcloud貌似採用的是拉模型。

關於這點我是深表贊同,我們有一個服務,需要將其資訊傳送到一個統計平臺共後續的資訊,開始的時候,我們使用推模式,每產生一條記錄,我們直接通過http推給後面的統計平臺,終於,隨著壓力的增大,整個統計平臺被我們發掛了,拒絕服務。最終,我們採用了將資料寫到本地,然後通過另一個程式拉取再發送的方式解決。

測試

soundcloud使用go的testing包進行測試,然後也使用flag的方式來進行整合測試,如下:

// +build integration

var fooAddr = flag.String(...)

func TestToo(t *testing.T) {
    f, err := foo.Connect(*fooAddr)
    // ...
}

因為go test也支援類似go build那種flag傳遞,它會預設合成一個main package,然後在裡面進行flag parse處理。

這種方式我現在沒有采用,我都是在測試用例裡面直接寫死了一個全域性的配置,主要是為了方便的在根目錄進行 go test ./...處理。不過使用flag的方式我覺得靈活性很大,後面如果有可能會考慮。

go的testing包提供的功能並不強,譬如沒有提供assert_equal這類東西,但是我們可以通過reflect.DeepEqual來解決。

依賴管理

這塊其實也是我非常想解決的。現在我們的程式碼就是很暴力的用go get來解決依賴問題,這個其實很有風險的,如果某一個依賴包更改了介面,那麼我們go get的時候可能會出問題了。

soundcloud使用了一種vendor的方式進行依賴管理。其實很簡單,就是把依賴的東西全部拷貝到自己的工程下面,當做自己的程式碼來使用。不過這個就需要定期的維護依賴包的更新了。

如果引入的是一個可執行包,在自己的工程目錄下面建立一個_vendor資料夾(這樣go的相關tool例如go test就會忽略該資料夾的東西)。把_vendor作為單獨的GOPATH,例如,拷貝github.com/user/dep到_vendor/src/github.com/user/dep下面。然後將_vendor加入自己的GOPATH中,如下:

GO ?= go
GOPATH := $(CURDIR)/_vendor:$(GOPATH)

all: build

build:
    $(GO) build

如果引入的是一個庫,那麼將其放入vendor目錄中,將vendor作為package的字首,例如拷貝github.com/user/dep到vendor/user/dep,並更改所有的相關import語句。

因為我們並不需要頻繁的對這些引入的工程進行go get更新處理,所以大多數時候這樣做都很值。

我開始的時候也採用的是類似的做法,只不過我不叫vendor,而叫做3rd,後來為了方便還是決定改成直接go get,雖然知道這樣風險比較大。沒準後續使用godep可能是一個不錯的解決辦法。

構建和部署

soundcloud在開發過程中直接使用go build來構建系統,然後使用一個Makefile來處理正式的構建。

因為soundcloud主要部署很多無狀態的服務,類似Heroku提供了很簡單的一種方式:

$ git push bazooka master
$ bazooka scale -r <new> -n 4 ...
$ # validate
$ bazooka scale -r <old> -n 0 ...

這方面,我們直接使用一個簡單的Makefile來構建系統,如下:

all: build 

build:
    go install ${SRC}

clean:
    go clean -i ${SRC}

test:
    go test ${SRC} 

應用程式的釋出採用最原始的scp到目標機器在重啟的方式,不過現在正在測試使用salt來發布應用。而對於應用程式的啟動,停止這些,我們則使用supervisor來進行管理。

總結

總的來說,這篇文章很詳細的講解了用go進行產品開發過程中的很多經驗,希望對大家有幫助。