這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
原文: Go Learn : Learning as we Go
作者: Peter Kelly,Teamwork Desk的進階工程師
翻譯: 孫薇
責編: 錢曙光,關注架構和演算法領域,尋求報道或者投稿請發郵件qianshg@csdn.net,另有「CSDN 進階架構師群」,內有諸多知名互連網公司的大牛架構師,歡迎架構師加qshuguang2008申請入群,備忘姓名+公司+職位。
摘要: Teamwork團隊在去年寫了近20萬行Go代碼,建造了一堆速度奇快的小型HTTP服務,本文列出了他們總結的9條經驗教訓。
為什麼選擇Go語言?Go語言,又稱Golang,是Google開發的一款靜態強型別、編譯型、並髮型,並具有記憶體回收機制的程式設計語言,它的運行速度非常之快,同時還有如下特性:具有一流的標準庫、無繼承關係、支援多核;同時它還有著傳說級的設計者與極其優秀的社區支援,更別提還有對於我們這些web應用的編寫者異常方便、可以避免事件迴圈與回調地獄的goroutine-per-request設定了(每次請求處理都需要啟動一個獨立的goroutine)。目前,Go語言已經成為構建系統、伺服器,特別是微服務的熱門選擇。
正如使用其它新興語言或技術一樣,我們在早期的實驗階段經曆了好一陣子的摸索期。Go語言確實有自己的風格與使用習慣,尤其是對於從物件導向語言(比如Java)或指令碼語言(比如Python)轉過來的開發人員而言更是如此。所以我們很是犯了些錯誤,在本文中我們希望能與大家分享所得。如果在生產環境中使用Go語言,下面這些問題都有可能碰到,希望本文能為Go語言的初學者提供一些協助。
1. Revel不是好的選擇
對於初學Go語言、需要構建web伺服器的使用者來說,他們也許會認為此時需要一個合適的架構。使用MVC架構確有優勢,主要是由於慣例優先原則設定了一系列的項目架構與慣例,從而賦予了項目一致性,並降低了跨項目開發的門檻。但我們發現:自行配置比遵循慣例更為強大,尤其是Go語言已經將編寫web應用的難度降到了最低,而我們的很多web應用都是小型服務。最重要的是:我們的應用不符合慣例。
Revel的設計初衷在於:嘗試將Play或Rails之類的架構引入Go語言,而不是運用Go與stdlib的力量,並以其為基礎進行構建。根據Go語言編寫者的說法:
最初這隻是一個有趣的項目,我想嘗試能否在不那麼神奇的Go語言中複製神奇的Play架構體驗。
公平來講,那時候在一種新語言中採用MVC架構對我們來說很有意義——無需爭論架構,同時新團隊也能連貫地構建內容。在使用Go語言之前,我所編寫的每個web應用都有著藉助MVC架構的痕迹。在C#中使用了ASP.NET MVC,在Java中使用了SpringMVC,在PHP中使用了Symfony,在Python中使用了CherryPy,在Ruby中使用了RoR,但最後我們終於發現,在Go語言中不需要架構。標準庫HTTP包已經包含所需的內容了,一般只要加入多工器(比如 mux)來選擇路由,再加入lib來處理中介軟體(比如 negroni)的任務(包括身分識別驗證與登入等)就足夠了。
Go的標準庫HTTP包設計讓這項工作十分簡單,使用者會漸漸發現:Go的強大有一部分原因就在於其工具鏈與相關的工具——其中包含各種可運行在代碼中的強大命令。但在Revel中,由於項目架構的設定,再加上缺乏package main與func main() {}入口(這些都是慣用和必要的Go命令),我們無法使用這些工具。事實上Revel附帶自己的命令包,鏡像一些類似run與build之類的命令。
使用Revel後,我們:
- 無法運行
go build;
- 無法運行
go install;
- 無法使用 race detector (–race);
- 無法使用
go-fuzz或者其它需要可構建Go資源的強大工具;
- 無法使用其它中介軟體或者路由;
- 熱重載雖然簡潔,但很緩慢,Revel在源上使用了反射機制(reflection),且從1.4版本來看,編譯時間也增加了大約30%。由於並未使用
go install,程式包沒有緩衝;
- 由於在Go 1.5及以上版本中編譯速度更慢,因此無法遷移到高版本,為了將核心升級到1.6版,我們去掉了Revel;
- Revel將測試放置在/test dir下面,違反了Go語言中將
_test.go檔案與測試檔案打包在一起的習慣;
- 要想運行Revel測試,需要啟動伺服器並執行整合測試。
我們發現Revel的很多方式與Go語言的構建習慣相去甚遠,同時也失去了一些強大go工具集的協助。
2. 明智地使用Panics
如果你是從Java或C#轉到Go語言的開發人員,可能會有些不太習慣Go語言中的錯誤處理方式(error handling)。在Go語言中,函數可返回多個值,因此在返回其他值時一併返回error是很典型的情況,如果一切運行正常的話,resturnsError返回的值為nil(nil是Go語言中參考型別的預設值)。
func something() (thing string, err error) { s := db.GetSomething() if s == "" { return s, errors.New("Nothing Found") } return s, nil}
由於我們想要建立一個error,並在調用棧的更高層級中進行處理,因此最終使用了panic。
s, err := something() if err != nil { panic(err)}
結果我們完全驚呆了:一個error?天啊,運行它!
但在Go中,你會發現error其實也是傳回值,在函數調用和響應處理中十分常見,而panic則會拖慢應用的效能,並導致崩潰——類似運行異常時的崩潰。為什麼要僅僅因為需要函數返回error就這樣做呢?這是我們的教訓。在1.6 版本發布前,轉儲panic的堆棧也負責轉儲所有啟動並執行Go程式,導致在尋找問題起源時非常困難,我們在一大堆不相關的內容上尋找了很久,白費力氣。
就算有一個真正不可恢複的error,或是遇到了運行時的panic,很可能你也並不希望整個web伺服器崩潰,因為它也是很多其他服務的中介軟體(你的資料庫也使用事務機制對吧?) 因此我們學到了處理這些panic的方式:在Revel中添加filter能夠讓這些panic恢複,還能擷取記錄檔中的堆棧追蹤記錄並發送到Sentry,然後通過電郵以及Teamwork Chat即時聊天工具給我們發送警告,API向前端返回“500內部伺服器錯誤”。
// PanicFilter wraps the action invocation in a protective defer blanket that// recovers panics, logs everything, and returns 500.func PanicFilter(rc *revel.Controller, fc []revel.Filter) { defer func() { if err := recover(); err != nil { handleInvocationPanic(rc, err) // stack trace, logging. alerting } }() fc[0](rc, fc[1:])}
3. 當心不止一次從Request.Body的讀取
從http.Request.Body讀取內容之後,其Body就被抽空了,隨後再次讀取會返回空body[]byte{} 。這是因為在讀取一個http.Request.Body的資料時,讀取器會停在資料的末尾,想要再次讀取必須先進行重設。然而,http.Request.Body是一個io.ReadWriter,並未提供Peek或Seek之類能解決這個問題的方法。有一個解決辦法是先將Body複製到記憶體中,讀取之後再將原本的內容填回去。如果有大量request的話,這種方式的開銷很大,只能算權宜之計。
下面是一段短小而完整的代碼:
package mainimport ( "bytes" "fmt" "io/ioutil" "net/http")func main() { r := http.Request{} // Body is an io.ReadWriter so we wrap it up in a NopCloser to satisfy that interface r.Body = ioutil.NopCloser(bytes.NewBuffer([]byte("test"))) s, _ := ioutil.ReadAll(r.Body) fmt.Println(string(s)) // prints "test" s, _ = ioutil.ReadAll(r.Body) fmt.Println(string(s)) // prints empty string! }
這裡包括複製及回填的代碼:
content, _ := ioutil.ReadAll(r.Body) // Replace the body with a new io.ReadCloser that yields the same bytesr.Body = ioutil.NopCloser(bytes.NewBuffer(content)) again, _ = ioutil.ReadAll(r.Body)
可以建立一些util函數:
func ReadNotDrain(r *http.Request) (content []byte, err error) { content, err = ioutil.ReadAll(r.Body) r.Body = ioutil.NopCloser(bytes.NewBuffer(content)) return}
以替代調用類似ioutil.ReadAll的方式:
content, err := ReadNotDrain(&r)
當然,現在你已經用no-op替換了r.Body.Close(),在request.Body中調用Close時將不會執行任何操作,這也是httputil.DumpRequest的工作方式。
4. 一些持續最佳化的庫有助於SQL的編寫
在Teamwork Desk,向使用者提供web應用服務的核心功能常要涉及MySQL,而我們沒有使用儲存程式,因此在Go之中的資料層包含一些很複雜的MySQL……而且某些代碼所構建的查詢複雜程度,足以媲美奧林匹克體操比賽的冠軍。一開始,我們用Gorm及其可鏈API來構建SQL,在Gorm中仍可使用原始的SQL,並讓它根據你的結構來產生結果(但在實踐中,近來我們發現這類操作越來越頻繁,這代表著我們需要重新調整使用Gorm的方式,以確保找到最佳方式,或者需要多看些替代方案——但也沒什麼好怕的!)
對於一些人來說,對象關係映射(ORM)非常糟糕,它會讓人失去控制力與理解力,以及最佳化查詢的可能性,這種想法沒錯,但我們只是用Gorm作為構建查詢(能理解其輸出的那部分)的封裝方式,而不是當作ORM來完全使用。在這種情況下,我們可以像下面這樣使用其可鏈API來構建查詢,並根據具體結構來調整結果。它的很多功能方便在代碼中手寫SQL,還支援Preloading、Limits、Grouping、Associations、Raw SQL、Transactions等操作,如果你要在Go語言中手寫SQL代碼,那麼這種方法值得一試。
var customer Customer query = db. Joins("inner join tickets on tickets.customersId = customers.id"). Where("tickets.id = ?", e.Id). Where("tickets.state = ?", "active"). Where("customers.state = ?", "Cork"). Where("customers.isPaid = ?", false). First(&customer)
5. 無指向的指標是沒有意義的
實際上這裡特指切片(slice)。你在向函數傳值時使用到了切片?在Go語言中,數組(array)也是數值,如果有大量的數組的話,你也不希望每次傳值或者分配時都要複製一下吧?沒錯,讓記憶體傳遞數組的開銷是很大的,但在Go語言中,99%的時間裡我們處理的都是切片而不是數組。一般來講,切片可以當成數組部分區段的描述(經常是全部的片段),包含指向數組開始元素的指標、切片的長度與容量。
切片的每個部分只需要8個位元組, 因此無論底層是什麼,數組有多大都不會超過24個位元組。
我們經常向函數切片發送指標,以為能節省空間的。
t := getTickets() // e.g. returns []Tickets, a slice ft := filterTickets(&t)func filterTickets(t *[]Tickets) []Tickets {}
如果t中有很多資料,我們以為將其發送給filterTicket能阻止在記憶體中執行大量資料的複製工作。而現在隨著對切片的瞭解,我們知道:可以只發送切片值,不用擔心記憶體的問題。
t := getTickets() // []Tickets massive list of tickets, 20MB ft := filterTickets(t)func filterTickets(t []Tickets) []Tickets {} // 24 bytes passed by value
當然,不通過引用來發送也代表著你不會對指標指向進行錯誤的更改,因為切片本身就是一個參考型別。
6. Naked returns會損失可讀性,讓代碼更難讀懂(在較大的函數中)。
在Go語言中,“Naked returns”指代從某個函數返回時,沒有明確說明返回內容的return。
Go語言可以有命名傳回值,比如func add(a, b int) (total int) {}, 我可以使用剛返回的那個函數來執行返回,而無需返回全部內容(使用return來代替return all)。在小型函數中,Naked Returns非常有用,也很簡潔。
func findTickets() (tickets []Ticket, countActive int64, err error) { tickets, countActive = db.GetTickets() if tickets == 0 { err = errors.New("no tickets found!") } return}
顯而易見,如果沒找到ticket,則返回0, 0, error;如果找到了ticket,則返回120, 80, nil之類的格式,具體數值取決於ticket的count。關鍵在於:如果在函數簽名中命名了傳回值,就可以使用return(naked return),在調用返回時,也會返回每個命名傳回值所在的狀態。
然而,我們有一些大型函數,大到有些笨重的那種。在函數中的,任何長度需要翻頁的naked returns都會極大地影響可讀性,並容易造成細微不易察覺的bug。特別如果有多個返回點的話,千萬不要使用naked returns或者大型函數。
下面是一個例子:
func findTickets() (tickets []Ticket, countActive int64, err error) { tickets, countActive := db.GetTickets() if tickets == 0 { err = errors.New("no tickets found!") } else { tickets += addClosed() // return, hmmm...okay, I might know what this is return } . . . // lots more code . . . if countActive > 0 { countActive - closedToday() // have to scroll back up now just to be sure... return } . . . // Okay, by now I definitely can't remember what I was returning or what values they might have return}
7. 當心範圍與縮減聲明
在Go語言中,如果在不同的塊區內使用相同的縮減名:=來聲明變數時,由於範圍(scope)的存在,會出現一些細微不易察覺的bug,我們稱之為shadowing。
func findTickets() (tickets []Ticket, countActive int64) { tickets, countActive := db.GetTickets() // 10 tickets returned, 3 active if countActive > 0 { // oops, tickets redeclared and used just in this block tickets, err := removeClosed() // 6 tickets left after removing closed if err != nil { // Argh! We used the variables here for logging!, if we didn't we would // have received a compile-time error at least for unused variables. log.Printf("could not remove closed %s, ticket count %d", err.Error(), len(tickets)) } } return // this will return 10 tickets o_O}
具體在於:=縮減變數的聲明與分配問題,一般來說如果在左邊使用新變數時,才會編譯:=,但如果左邊出現其他新變數的話,也是有效。在上例中,err是新變數,因為在函數返回的參數中已經聲明過,你以為ticket會被自動覆蓋。但事實並非如此,由於塊區範圍的存在,在聲明和分配新的ticket變數後,一旦塊區閉合,其範圍就會丟失。為瞭解決這個問題,我們只需聲明變數err位於塊區之外,再用=來代替:=,優秀的編輯器(比如加入Go外掛程式的Emacs或Sublime就能解決這個shadowing的問題)。
func findTickets() (tickets []Ticket, countActive int64) { var err error tickets, countActive := db.GetTickets() // 10 tickets returned, 3 active if countActive > 0 { tickets, err = removeClosed() // 6 tickets left after removing closed if err != nil { log.Printf("could not remove closed %s, ticket count %d", err.Error(), len(tickets)) } } return // this will return 6 tickets}
8. 映射與隨機崩潰
在並發訪問時,映射並不安全。我們曾出現過這個情況:將映射作為應用整個生命週期的應用級變數,在我們的應用中,這個映射是用來收集每個控制器統計資料的,當然在Go語言中每個http request都是自己的goroutine。
你可以猜到下面會發生什麼,實際上不同的goroutine會嘗試同時訪問映射,也可能是讀取,也可能是寫入,可能會造成panic而導致應用崩潰(我們在Ubuntu中使用了upstart指令碼,在進程停止時重啟應用,至少保證應用算是“線上”)。有趣的是:這種情況隨機出現,在1.6版本之前,想要找出像這樣出現panic的原因都有些費勁,因為堆棧轉儲包含所有運行狀態下的goroutine,從而導致我們需要過濾大量的日誌。
在並發訪問時,Go團隊的確考慮過映射的安全性問題,但最終放棄了,因為在大多數情況下這種方式會造成非必要開銷,在golang.org的FAQ中有這樣的解釋:
在經過長期討論後,我們決定在使用映射時,一般不需從多個goroutine執行安全訪問。在確實需要安全訪問時,映射很可能屬於已經同步過的較大資料架構或者計算。因此,如果要求所有映射操作需要互斥鎖的話,會拖慢大多數程式,但效果寥寥無幾。由於不經控制的映射訪問會讓程式崩潰,作出這個決定並不容易。
我們的代碼看起來就象這樣:
package statsvar Requests map[*revel.Controller]*RequestLog var RequestLogs map[string]*PathLog
我們對其進行了修改,使用stdlib的同步資料包:在封裝映射的結構中嵌入讀取/寫入互斥鎖。我們為這個結構添加了一些helper:Add與Get方法:
var Requests ConcurrentRequestLogMap// init is run for each package when the app first runsfunc init() { Requests = ConcurrentRequestLogMap{items: make(map[interface{}]*RequestLog)}}type ConcurrentRequestLogMap struct { sync.RWMutex // We embed the sync primitive, a reader/writer Mutex items map[interface{}]*RequestLog}func (m *ConcurrentRequestLogMap) Add(k interface{}, v *RequestLog) { m.Lock() // Here we can take a write lock m.items[k] = v m.Unlock()}func (m *ConcurrentRequestLogMap) Get(k interface{}) (*RequestLog, bool) { m.RLock() // And here we can take a read lock v, ok := m.items[k] m.RUnlock() return v, ok}
現在再也不會崩潰了。
9. Vendor的使用
好吧,雖然難以啟齒,但我們剛好犯了這個錯誤,罪責重大——在將代碼部署到生產環境時,我們居然沒有使用vendor。
簡單解釋一下,在Go語言中,我們通過從項目根目錄下運行go get ./...來獲得依賴, 每個依賴都需要從主伺服器的HEAD上拉取,很顯然這種情況非常糟糕,除非在$GOPATH的伺服器上儲存依賴的準確版本,並且一直不做更新(也不重新構建或運行新的伺服器),如果更改無可迴避,你會對生產環境中啟動並執行代碼失去控制。在Go 1.4版本中,我們使用了Godeps及其GOPATH來執行vendor;在1.5版本中,我們使用了GO15VENDOREXPERIMENT環境變數;到了1.6版本,終於不需要工具了——項目根目錄下的/vendor可以自動識別為依賴的存放位置。你可以在不同的vendor工具中選擇一個來追蹤版本號碼,讓依賴的添加與更新更為簡單(移除.git,更新清單等)。
所獲良多,但學無止境
上面僅僅列出了我們初期所犯錯誤與所獲心得的一小部分。我們只是由5名開發人員組成的小團隊,建立了Teamwork Desk,儘管去年我們在Go語言方面所獲良多,但還有大批的優秀功能蜂擁而至。今年我們會出席各種關於Go語言的大會,包括在丹佛舉行的GopherCon大會;另外我還在Cork的當地開發人員聚會上就Go的使用進行了討論。
我們會繼續發布Go語言相關的開源工具,並致力於回饋現有的庫。目前我們已經適當提供了一些小型項目(參見列表),所發的Pull Request也被Stripe、Revel以及一些其他的開源Go項目所採納。
- s3pp
- stripehooks
- tnef parser