1. 程式人生 > >為什麽選擇Go語言 GO語言都能做什麽產品

為什麽選擇Go語言 GO語言都能做什麽產品

自己的 結構 over 隨機 chat req ice nts 因此

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語言中引用類型的默認值)。
Java代碼
  1. func something() (thing string, err error) {
  2. s := db.GetSomething()
  3. if s == "" {
  4. return s, errors.New("Nothing Found")
  5. }
  6. return s, nil
  7. }

由於我們想要創建一個error,並在調用棧的更高層級中進行處理,因此最終使用了panic。
Java代碼
  1. s, err := something()
  2. if err != nil {
  3. panic(err)
  4. }

結果我們完全驚呆了:一個error?天啊,運行它!

但在Go中,你會發現error其實也是返回值,在函數調用和響應處理中十分常見,而panic則會拖慢應用的性能,並導致崩潰——類似運行異常時的崩潰。為什麽要僅僅因為需要函數返回error就這樣做呢?這是我們的教訓。在1.6 版本發布前,轉儲panic的堆棧也負責轉儲所有運行的Go程序,導致在查找問題起源時非常困難,我們在一大堆不相關的內容上查找了很久,白費力氣。

就算有一個真正不可恢復的error,或是遇到了運行時的panic,很可能你也並不希望整個web服務器崩潰,因為它也是很多其他服務的中間件(你的數據庫也使用事務機制對吧?) 因此我們學到了處理這些panic的方式:在Revel中添加filter能夠讓這些panic恢復,還能獲取日誌文件中的堆棧追蹤記錄並發送到Sentry,然後通過電郵以及Teamwork Chat實時聊天工具給我們發送警告,API向前端返回“500內部服務器錯誤”。
Java代碼
  1. // PanicFilter wraps the action invocation in a protective defer blanket that
  2. // recovers panics, logs everything, and returns 500.
  3. func PanicFilter(rc *revel.Controller, fc []revel.Filter) {
  4. defer func() {
  5. if err := recover(); err != nil {
  6. handleInvocationPanic(rc, err) // stack trace, logging. alerting
  7. }
  8. }()
  9. fc[0](rc, fc[1:])
  10. }

3. 當心不止一次從Request.Body的讀取
從http.Request.Body讀取內容之後,其Body就被抽空了,隨後再次讀取會返回空body[]byte{} 。這是因為在讀取一個http.Request.Body的數據時,讀取器會停在數據的末尾,想要再次讀取必須先進行重置。然而,http.Request.Body是一個io.ReadWriter,並未提供Peek或Seek之類能解決這個問題的方法。有一個解決辦法是先將Body復制到內存中,讀取之後再將原本的內容填回去。如果有大量request的話,這種方式的開銷很大,只能算權宜之計。

下面是一段短小而完整的代碼:
Java代碼
  1. package main
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io/ioutil"
  6. "net/http"
  7. )
  8. func main() {
  9. r := http.Request{}
  10. // Body is an io.ReadWriter so we wrap it up in a NopCloser to satisfy that interface
  11. r.Body = ioutil.NopCloser(bytes.NewBuffer([]byte("test")))
  12. s, _ := ioutil.ReadAll(r.Body)
  13. fmt.Println(string(s)) // prints "test"
  14. s, _ = ioutil.ReadAll(r.Body)
  15. fmt.Println(string(s)) // prints empty string!
  16. }

這裏包括復制及回填的代碼:
Java代碼
  1. content, _ := ioutil.ReadAll(r.Body)
  2. // Replace the body with a new io.ReadCloser that yields the same bytes
  3. r.Body = ioutil.NopCloser(bytes.NewBuffer(content))
  4. again, _ = ioutil.ReadAll(r.Body)

可以創建一些util函數:
Java代碼
  1. func ReadNotDrain(r *http.Request) (content []byte, err error) {
  2. content, err = ioutil.ReadAll(r.Body)
  3. r.Body = ioutil.NopCloser(bytes.NewBuffer(content))
  4. return
  5. }

以替代調用類似ioutil.ReadAll的方式:
Java代碼
  1. 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代碼,那麽這種方法值得一試。
Java代碼
  1. var customer Customer
  2. query = db.
  3. Joins("inner join tickets on tickets.customersId = customers.id").
  4. Where("tickets.id = ?", e.Id).
  5. Where("tickets.state = ?", "active").
  6. Where("customers.state = ?", "Cork").
  7. Where("customers.isPaid = ?", false).
  8. First(&customer)

5. 無指向的指針是沒有意義的
實際上這裏特指切片(slice)。你在向函數傳值時使用到了切片?在Go語言中,數組(array)也是數值,如果有大量的數組的話,你也不希望每次傳值或者分配時都要復制一下吧?沒錯,讓內存傳遞數組的開銷是很大的,但在Go語言中,99%的時間裏我們處理的都是切片而不是數組。一般來講,切片可以當成數組部分片段的描述(經常是全部的片段),包含指向數組開始元素的指針、切片的長度與容量。

切片的每個部分只需要8個字節, 因此無論底層是什麽,數組有多大都不會超過24個字節。

技術分享圖片
我們經常向函數切片發送指針,以為能節省空間。
Java代碼
  1. t := getTickets() // e.g. returns []Tickets, a slice
  2. ft := filterTickets(&t)
  3. func filterTickets(t *[]Tickets) []Tickets {}

顯而易見,如果沒找到ticket,則返回0, 0, error;如果找到了ticket,則返回120, 80, nil之類的格式,具體數值取決於ticket的count。關鍵在於:如果在函數簽名中命名了返回值,就可以使用return(naked return),在調用返回時,也會返回每個命名返回值所在的狀態。

然而,我們有一些大型函數,大到有些笨重的那種。在函數中的,任何長度需要翻頁的naked returns都會極大地影響可讀性,並容易造成細微不易察覺的bug。特別如果有多個返回點的話,千萬不要使用naked returns或者大型函數。

下面是一個例子:
Java代碼
  1. func findTickets() (tickets []Ticket, countActive int64, err error) {
  2. tickets, countActive := db.GetTickets()
  3. if tickets == 0 {
  4. err = errors.New("no tickets found!")
  5. } else {
  6. tickets += addClosed()
  7. // return, hmmm...okay, I might know what this is
  8. return
  9. }
  10. .
  11. .
  12. .
  13. // lots more code
  14. .
  15. .
  16. .
  17. if countActive > 0 {
  18. countActive - closedToday()
  19. // have to scroll back up now just to be sure...
  20. return
  21. }
  22. .
  23. .
  24. .
  25. // Okay, by now I definitely can‘t remember what I was returning or what values they might have
  26. return
  27. }

7. 當心作用域與縮略聲明
在Go語言中,如果在不同的塊區內使用相同的縮略名:=來聲明變量時,由於作用域(scope)的存在,會出現一些細微不易察覺的bug,我們稱之為shadowing。
Java代碼
  1. func findTickets() (tickets []Ticket, countActive int64) {
  2. tickets, countActive := db.GetTickets() // 10 tickets returned, 3 active
  3. if countActive > 0 {
  4. // oops, tickets redeclared and used just in this block
  5. tickets, err := removeClosed() // 6 tickets left after removing closed
  6. if err != nil {
  7. // Argh! We used the variables here for logging!, if we didn‘t we would
  8. // have received a compile-time error at least for unused variables.
  9. log.Printf("could not remove closed %s, ticket count %d", err.Error(), len(tickets))
  10. }
  11. }
  12. return // this will return 10 tickets o_O
  13. }

具體在於:=縮略變量的聲明與分配問題,一般來說如果在左邊使用新變量時,才會編譯:=,但如果左邊出現其他新變量的話,也是有效的。在上例中,err是新變量,因為在函數返回的參數中已經聲明過,你以為ticket會被自動覆蓋。但事實並非如此,由於塊區作用域的存在,在聲明和分配新的ticket變量後,一旦塊區閉合,其作用域就會丟失。為了解決這個問題,我們只需聲明變量err位於塊區之外,再用=來代替:=,優秀的編輯器(比如加入Go插件的Emacs或Sublime就能解決這個shadowing的問題)。
Java代碼
  1. func findTickets() (tickets []Ticket, countActive int64) {
  2. var err error
  3. tickets, countActive := db.GetTickets() // 10 tickets returned, 3 active
  4. if countActive > 0 {
  5. tickets, err = removeClosed() // 6 tickets left after removing closed
  6. if err != nil {
  7. log.Printf("could not remove closed %s, ticket count %d", err.Error(), len(tickets))
  8. }
  9. }
  10. return // this will return 6 tickets
  11. }

8. 映射與隨機崩潰
在並發訪問時,映射並不安全。我們曾出現過這個情況:將映射作為應用整個生命周期的應用級變量,在我們的應用中,這個映射是用來收集每個控制器統計數據的,當然在Go語言中每個http request都是自己的goroutine。

你可以猜到下面會發生什麽,實際上不同的goroutine會嘗試同時訪問映射,也可能是讀取,也可能是寫入,可能會造成panic而導致應用崩潰(我們在Ubuntu中使用了upstart腳本,在進程停止時重啟應用,至少保證應用算是“在線”)。有趣的是:這種情況隨機出現,在1.6版本之前,想要找出像這樣出現panic的原因都有些費勁,因為堆棧轉儲包含所有運行狀態下的goroutine,從而導致我們需要過濾大量的日誌。

在並發訪問時,Go團隊的確考慮過映射的安全性問題,但最終放棄了,因為在大多數情況下這種方式會造成非必要開銷,在golang.org的FAQ中有這樣的解釋:

在經過長期討論後,我們決定在使用映射時,一般不需從多個goroutine執行安全訪問。在確實需要安全訪問時,映射很可能屬於已經同步過的較大數據架構或者計算。因此,如果要求所有映射操作需要互斥鎖的話,會拖慢大多數程序,但效果寥寥無幾。由於不經控制的映射訪問會讓程序崩潰,作出這個決定並不容易。
我們的代碼看起來就象這樣:
Java代碼
  1. package stats
  2. var Requests map[*revel.Controller]*RequestLog
  3. var RequestLogs map[string]*PathLog

我們對其進行了修改,使用stdlib的同步數據包:在封裝映射的結構中嵌入讀取/寫入互斥鎖。我們為這個結構添加了一些helper:Add與Get方法:
Java代碼
  1. var Requests ConcurrentRequestLogMap
  2. // init is run for each package when the app first runs
  3. func init() {
  4. Requests = ConcurrentRequestLogMap{items: make(map[interface{}]*RequestLog)}
  5. }
  6. type ConcurrentRequestLogMap struct {
  7. sync.RWMutex // We embed the sync primitive, a reader/writer Mutex
  8. items map[interface{}]*RequestLog
  9. }
  10. func (m *ConcurrentRequestLogMap) Add(k interface{}, v *RequestLog) {
  11. m.Lock() // Here we can take a write lock
  12. m.items[k] = v
  13. m.Unlock()
  14. }
  15. func (m *ConcurrentRequestLogMap) Get(k interface{}) (*RequestLog, bool) {
  16. m.RLock() // And here we can take a read lock
  17. v, ok := m.items[k]
  18. m.RUnlock()
  19. return v, ok
  20. }

現在再也不會崩潰了。

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項目所采納。

為什麽選擇Go語言 GO語言都能做什麽產品