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

為什麼選擇Go語言 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語言中引用型別的預設值)。 
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.   
  3. import (    
  4.     "bytes"  
  5.     "fmt"  
  6.     "io/ioutil"  
  7.     "net/http"  
  8. )  
  9.   
  10. func main() {    
  11.     r := http.Request{}  
  12.     // Body is an io.ReadWriter so we wrap it up in a NopCloser to satisfy that interface  
  13.     r.Body = ioutil.NopCloser(bytes.NewBuffer([]byte("test")))  
  14.   
  15.     s, _ := ioutil.ReadAll(r.Body)  
  16.     fmt.Println(string(s)) // prints "test"  
  17.   
  18.     s, _ = ioutil.ReadAll(r.Body)  
  19.     fmt.Println(string(s)) // prints empty string!   
  20. }  

這裡包括複製及回填的程式碼: 
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.   
  4. 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.   
  3. var Requests map[*revel.Controller]*RequestLog    
  4. var RequestLogs map[string]*PathLog    

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

現在再也不會崩潰了。 

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專案所採納。