產品環境中 Go 語言的最佳實踐
在SoundCloud,我們為客戶構建了產品的API。或者說,我們主要的網站、手機客戶端和手機應用是該API的第一批客戶。該API背后是一個領域性的服務:SoundCloud基本上以面向服務體系結構的形式運作。
我們也是通曉多種語言的組織,因為我們使用了很多語言。并且這些服務(和基礎設施支持)的許多部分是使用Golang開發的。事實上,我們都是早期Golang的使用者:目前,我們已在產品中使用Golang有兩年半的時間。相關項目包括:
Bazooka,我們內部服務平臺;產品思想非常類似于Keroku或Flynn。
我們外圍的傳輸層使用通用的nginx, HAProxy等等,但是它們要和Golang服務協作。
我們的音頻存儲在AWS S3上,但是上傳、轉碼和生成鏈接等需要Golang服務協調處理。
搜索采用了Elasticsearch, 探測使用復雜的機器學習模型,但是它們都與由Golang開發的基礎設施相集成。
Prometheus,一個早期階段的遙測系統純粹是有Golang開發。
當前,流處理采用Cassandra,但是我們正打算(幾乎)完全使用Golang代替。
我們也正在試驗用Golnag開發的HTTP流媒體直播服務。
許多其他面向產品的小服務。
這些項目大概有六個團隊開發,包括十多人的SoundCloud勤雜工,他們中的大部分會全職使用Golang。畢竟在這個時候,這些項目和這樣混雜的工程師中,我們已經逐漸形成了在產品中使用Golang的最好實踐方法。我們的這些教訓將對其他開始大舉投資Golang的組織提供幫助。
開發環境
在我們的筆記本上,我們已經設定了單一、全局的GOPATH。就個人而言,我喜歡使用$HOME,但是許多其他人使用$HOME下的一個子目錄。我們克隆倉庫進入GOPATH的相對路徑,然后就可直接工作。即,
$ mkdir -p $GOPATH/src/github.com/soundcloud $ cd $GOPATH/src/github.com/soundcloud $ git clone git@github.com:soundcloud/roshi
我們中的許多人在早期一直和約定俗成的事情做斗爭,以保持我們自己特有的代碼組織方法。事實上,它根本不值得如此麻煩。
對于編輯器,許多用戶使用Vim以及各種插件。(我使用的vim-go就不錯。)還有許多人,包括我自己也是,結合GoSublime使用Sublime Text。也有少數人使用Emacs,但沒有人用IDE。我不確定這是不是個最佳的實踐,但標出來挺有趣的。
庫結構
我們的最佳實踐是確保任何事情簡單。許多服務源碼半打包在main包中。
github.com/soundcloud/simple/ README.md Makefile main.go main_test.go support.go support_test.go
比如我們的搜索調度器,兩年后仍然是這樣。在確定需要前不要創建新結構。
也許在某些時候你需要創建一個新的支持包。在你的main庫中使用子目錄,并使用完整的限定名導入。如果該包只有一個文件或一個結構,那么它肯定不需要分拆出來。
有時一個倉庫中需要包含多個二進制文件;比如這個任務需要一個服務,一個工作進程,或一個監控。在這種情況下,將每個二進制文件放在特定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
請注意,不要引入asrc目錄。由于vendor子目錄異常(下面介紹更多內容)不要在倉庫中包含src目錄,或將其添加到GOPATH。
格式及樣式
通常來說,首先配置你的編輯器保存代碼交給go fmt(或goimports),使用默認參數。這意味使用tab縮進,用空格對齊。格式不正確的代碼將不能提交。
過去的風格指南非常廣泛,但谷歌最近發布了他們的 代碼審查意見 文檔,這幾乎就是我們應遵守的公約。因此,我們使用它。
實際上我們把它推進了一點:
避免命名返回參數,除非他們能明確和顯著地提高透明度。
避免用 make 和 new,除非他們是必要的(new(int),或 make(Chan int)),或者我們能提前知道要分配的東西的尺寸( make(map[int]string,n),或 make([]int,0,256))。
使用 struct{} 作為標記值,而不是布爾或接口{}。例如,集合是 map[string]struct{};信道是 chan struct{}。它明確標明了信息的明確缺乏。
打斷長行的參數也很好。那更象是Java的風格:
// 不要這樣。 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, }, })
另外,當分配新的對象時,在初始化部分傳遞成員值(如上面)比下面這樣過后設置要好。
// 不要這樣。 f := &Foo{} // or, even worse: new(Foo) f.Site = "zombo.com" f.Out = os.Stdout f.Dest.Key = "gophercon" f.Dest.Value = 2014
配置
我們嘗試了通過多種方式向Go程序傳遞配置:解析配置文件,用 os.Getenv 直接從環境中提取配置,各種增值flag解析包。最后,最合乎經濟原則的就是普通的package flag,它的嚴格類型和簡單語義對我們所需的一切都絕對夠用而且夠好。
我們主要部署12-Factor 的應用,12-Factor 應用程序通過環境傳遞配置。但即使這樣,我們也使用一個啟動腳本來把環境變量轉換為flags。Flags作為程序及其運行環境之間的一個明確和全文檔化的表面區域。他們對于了解和操作程序來說是非常寶貴的。
一個關于flags的不錯的習慣是把他們定義到你的main函數中。這樣就能防止你在代碼中隨意的將他們作為全局變量使用,這使你嚴格的遵守依賴注入從而方便測試。
func main() { var ( payload = flag.String("payload", "abc", "payload data") delay = flag.Duration("delay", 1*time.Second, "write delay") ) flag.Parse() // ... }
日志和遙測
我們嘗試過幾個日志框架,他們提供像日志級別,調試,路由輸出,自定義格式化等等功能。最終我們選定package log。因為我們只記錄可操作信息。 這意味著需要人工處理的 serious, panic級別的錯誤,或者結構化數據會被其他機器消耗。 舉個例子,搜索轉發器發送每一個它使用上下文信息處理的請求,因此我們的分析工作流可以看到新西蘭的人們經常搜索 Lorde, 或者隨便什么。
我們考慮到遙測,在一個運行過程中釋放出的任何其他量:請求響應時間,QPS,運行錯誤,隊列深度等等。并且遙測基本上包括兩種模式:push和pull。
push意味著釋放指標到一個已知的系統。例如Graphite, Statsd, and AirBrake
pull意味著在一些已知的位置暴露指標,并允許已知的系統去擦除它們。例如,expvar和Prometheus(或許還有其他的)
</ul>
當然兩種方式都有自己的存在性。當你開始使用時,push是直觀和簡單的。但是推送指標的增長卻有悖常理:你得到的越大,成本越高。我們過去發現在特定規模大小的基礎設施上,pull是該尺度下的唯一模型。那也有許多值能反映一個運行的系統。所以,最好的實踐是:expvar或者類似風格的。
測試和驗證
在一年的過程中我們嘗試了許多的測試庫和框架,但是很快放棄了他們中的大部分,今天我們所有的測試通過數據驅動(表驅動)測試,用普通的包測試。我們沒有強烈或者明確的抱怨測試/檢查包,除此之外,他們根本沒有提供巨大的價值。有一件事情是有幫助的:reflect.DeepEqual讓你更簡單的對任意值進行比較(例如expected對got)。
包測試是面向單元測試的,對于集成測試,就會有點麻煩。運行的外部服務依賴于你的集成環境,但是我們找到了一個好的方式集成他們。寫一個integration_test.go,給它一個integration的構建標簽。定義(全局)標志,比如服務地址和連接字符串,用他們在你的測試中。
// +build integrationvar fooAddr = flag.String(...)
func TestToo(t testing.T) { f, err := foo.Connect(fooAddr) // ... }</pre>
go test 和 go build 一樣建立標簽,所以你可以調用 go test -tags=integration 。它也綜合了 flag.Parse 包的 main,所以任何被聲明和可見的 flags 將被處理和提供給你的測試。
通過驗證,我的意思是靜態代碼驗證。幸運的是,Go 有一些很好的工具。我發現當考慮使用哪種工具時考慮編寫代碼的階段很有用。
當做這種事時 | 使用這個 |
---|---|
保存 | go fmt(或 goimports) |
構建 | go vet,golint, 或者 go test |
部署 | go test -tags=integration |
插曲
到目前為止,還沒東西過于瘋狂。當做調查編撰這個列表的時候,讓我注意的只是如何。。。。。。結論如何的無趣。讓人沉悶。我想強調這些非常輕量,純標準庫的約定能真正推廣到大群體的開發人員和多元化的項目生態系統。你絕對不會僅僅因為你的代碼庫已經超過一定的規模,或者只是因為它可能 增長超過一定行數, 而需要你自己的查錯框架,或者測試庫。你真的是不會需要它的。標準的語法和用法在代碼大規模時仍然功能優雅。
依賴管理
依賴管理! 呃! ?( ? )?
依賴管理的狀態在 Go 生態系統中是一個熱門的爭論點,我們還沒有想到完美的解決方案。但是,我們選用了一個似乎不錯的妥協方案。
你的項目有多么重要? | 你的依賴管理方案是… |
---|---|
嗯… | go get -d,然后祈禱! |
很好. | VENDORING |
(值得提出的是,我們有令人震驚數量的長期產品服務,依然依賴于第一個選項.然而,因為我們一般沒有使用太多第三方代碼,以及主要問題通常在編譯階段就被檢測到,我們僥幸規避了這個問題.)
Vendoring意味著拷貝依賴到項目代碼庫,然后在編譯的時候使用它們.依賴于你下載的內容,這里有兩個vendoring的最佳實踐.
下載 | Vendor目錄名 | 過程 |
---|---|---|
二進制 | _vendor | 加GOPATH前綴編譯 |
庫 | vendor | 重寫import語句 |
如果下載二進制,就在代碼庫的根目錄創建一個_vendor子目錄.(帶上下劃線,這樣,go工具就會在處理時忽略它,例如go test ./...)對待它就像對待GOPATH一樣; 例如,拷貝這個依賴github.com/user/dep 到 _vendor/src/github.com/user/dep. 然后,編寫一個所謂的神圣的編譯過程,它將_vendor加入到可能存在的GOPATH之中. (記住: GOPATH 實際是一個路徑的列表,當go工具處理import時,會按順序搜索這個列表.)例如,你可能擁有一個頂層的Makefile文件,如下所示:
GO ?= go GOPATH := $(CURDIR)/_vendor:$(GOPATH) all: build build: $(GO) build
如果你正在下載某個類庫在你的根存儲庫上創建一個vendor子目錄。處理這件事就像在包目錄上加一個前綴。舉例來說,拷貝來自于github.com/user/dep的項目放到vendor/user/dep。在這之后,重寫你所有的引入(import),及其相互關系。此時是很痛苦的,當剩下的內容需要go get兼容的時候,看起來最有效的方式是確保事實上可重新構建(actually-reproducible build)。值得注意的是,我們在實踐中很少去下載類庫,因此這個辦法雖然麻煩卻很有效。
如何在實際中拷貝一個依賴關系到你自己的存儲庫是另外一個熱門的話題。最簡單的方法是從一個克隆(clone)中手動復制文件,如果你不關心上游部門的推送,這可能是最好的答案。有些人使用git子模塊,但我們發現它們非常違反直覺并難以管理(對許多 人來說也是這樣,這是有記錄的)。我們對于git子目錄(的管理)已經很成功,他工作起來就像是子模塊。還有大量的工具是用來自動處理這項工作的。現在,它看起來就像godep發展非常積極,而且還很值得研究。
構建與部署
構建與部署有其技巧性,因此它與你的操作環境耦合緊密。我要描述下我們的場景,因為我認為它是個好模型,但它可能無法直接應用到你的組織機構中。
就構建而言,我們通常直接使用 go build 來開發,以及一個 Makefile 用于剪裁官方構建。這主要是因為我們熟悉多種語言,并且我們的工具使用需要做到最小功能合集(最小公倍數)。并且,我們的構建系統始于一個空環境,也需要自備編譯器( Makefile 文件很難看!)。
對部署而言,對我們最大的吸引是無狀態之于有狀態。
模式 | 樣例 | 模型 | 部署名稱 | 部署形式 |
---|---|---|---|---|
無狀態 | Request router | 12-Factor | Scaling | Containers |
有狀態 | Redis | None, really | Provisioning | Containers? |