用Go構建Teamwork項目的9條教訓

我們愛 Go。


在過去的一年中,我們爲了構建 Teamwork Desk 多個服務,寫下了將近 20 萬行 Go 代碼。我們已經構建了該產品的十多個小型 HTTP 服務。


爲什麼要使用 Go?


Go 是一種快速(非常快)的靜態類型編譯語言,它有強大的併發模型、垃圾收集、優異的標準庫、無繼承、傳奇的作者、多核支持以及非常不錯的社區。更別說對於我們這種寫 Web 應用的程序員,它的 goroutine-per-request 設置可以避免事件循環和回調地獄。


在構建系統和服務器方面尤其是微服務,Go 語言已經成爲了大熱門。


正如使用任何新語言和技術一樣,我們在早期的實踐中經歷了一段跌跌撞撞的過程。Go 語言確實有自己的風格和語言特性,尤其當你原來使用的語言是 OO 語言(比如 Java)或腳本語言(比如 Python)時需要適應的過程。所以我們也犯了一些錯誤,我們願意和大家分享這些錯誤以及我們從中得到的教訓。


如果你在生產環境中使用 Go,你可能對所有問題似曾相識。如果你剛開始使用 Go,希望你能從下面的總結中找到一些借鑑。


1. 不用 Revel

剛開始使用 Go?構建 Web 服務器?需要一個框架吧?你可能會這麼認爲。使用 MVC 框架確實有一些優勢,這些優勢主要來自於“約定優於配置”,它給予項目的結構,這種方式可以提供一致性並且降低跨項目開發的門檻。


我們的觀點是相比於約定的優勢,我們更傾向於配置的力量,特別是用 Go 語言寫 Web 應用毫不費力,我們的很多 Web 應用都是小型服務。總的來說,這種方法不符合我們的語言習慣。


Revel 的基本觀念在於努力向 Go 中引入類似於 Play 或 Rails 這樣的框架,而不是使用 Go 和 stdlib 的力量並以此爲基礎向上構建。


Revel 作者是這樣說:


一開始這只是一個好玩的項目,我想看看是否可以複製神奇的 Play! 1.x 到不那麼神奇的 Go 語言中。


公正地說,在新語言中使用 MVC 框架對於當時的我們來說是很適合,因爲這樣做可以去除關於結構的爭論,讓新的團隊可以以一種連貫的方式開始構建。在使用 Go 語言之前,幾乎所有我寫的 Web 應用都多少藉助了一些 MVC 框架的方式。C#、ASP.NET MVC、Java SpringMVC。PHP  Symfony、Python  CherryPy、Ruby  RoR……最後我們意識到我們不需要在 Go 中使用框架。標準庫 HTTP 程序包已經擁有你需要的東西了,你通常只需要添加一個 multiplexer 多路複用器(比如 mux)用於路由選擇,以及一箇中間件(比如 negroni)的 lib 用於處理認證和登錄等,這就是你需要的全部了。


Go 的 HTTP 程序包設計讓一切都很簡單。你還會意識到 Go 的一些能力就存在於 Go 的工具鏈和 Go 周圍的工具中,這些工具會提供給你廣泛而強大命令。


但是在 Revel 中,因爲項目結構的設置,並且因爲其中不支持 package mainfunc main() {} 入口,對於很多 Go 命令來說這是符合習慣且必要的,但 Revel 不能使用這些工具。事實上 Revel 帶有自己的命令包,它會鏡像一些像 run 和 build 這樣的命令。


總結以下,使用 Revel:


  • 不能運行 go build 及 go install

  • 不能運行 race 探測器 (--race)

  • 不能使用 go-fuzz 或其他任何需要可構建 Go 源的工具

  • 不能使用其他中間件或路由器

  • 熱重載雖然簡潔,但很慢,Revel 在源代碼上使用反射,並且根據我們使用 1.4 版的經驗,增加了約 30% 的編譯時間。而且它還不使用 go install 因此包不會被緩存

  • 不能遷移到 Go 1.5 或更高版本,因爲在 Revel 中的編譯時間還要更慢。我們去掉了 Revel 並且把內核遷移到了 1.6 上。

  • Revel 把測試挪到了 /test 目錄下面,違反了 Go 把 _test.go 文件和被測試的文件一起放進相同程序包的慣例。

  • Revel 測試如果要運行,就會啓動你的服務器,從而進行集成測試。


我們發現 Revel 嚴重偏離了符合 Go 語言使用習慣的構建方式,而且我們失去了 Go 工具箱中的一些強大的部分。


2. 善用 Panic

如果你原來是一位 Java 或 C# 開發者,你可能需要適應一下在 Go 中處理錯誤的方式。Go 可以從函數返回多個值,所以調用結果和 error 一起返回是一種非常普通的情況。當然如果沒有異常的話 error 就會是 nil (nil 是 Go 中引用類型的默認值)。




我們最終也開始使用 panic,其實我真正想要的是想創建一個錯誤,並讓其被調用棧的上一級處理。




在 Go 中,通常 error 也是個返回值,而且是調用函數返回的正常的一部分。但 panic 就像一個 runtime 異常會搞掛你的應用如果只是一個函數返回了一個 error,你爲什麼要使用 panic?這是我們得到的心得體會。


在 1.6 之前,如果 panic dump 會把所有運行的 goroutines 全部 dump 出來,所以要定位出錯的點非常困難。你最終不得不費力做很多本不需要做的事。


哪怕你真的有一個不可恢復的錯誤,或者你遇到了一個運行時 panic,你大多並不想要停你的整個 Web  服務器,你的服務器可能還在處理其他事情(比如正在進行的數據庫事務)。


所以我們學會了如何處理這些事件,在 Revel 中添加過濾器,它可以恢復 panic 並且捕捉日誌文件的 stack trace,併發送到 Sentry (https://getsentry.com/welcome/),於是我們就會馬上在郵件和 Teamwork Chat 中獲得提醒。 API 會向前端返回 500 Internal Server Error




3. Request.Body 那些坑

在讀取了 http.Request.Body 之後,Body 就被讀空了,而隨後的讀取就會返回 []byte{} —— 一個空的 body。 這是因爲當你在讀取 http.Request.Body 的字節時,讀取器處於這些字節的結尾,需要重置才能再次讀取。但是 http.Request.Body 是一個 io.ReadWriter,並沒有 PeekSeek 這樣的方法。


一個解決方法是先把 body 複製進存儲空間,然後在讀取之後把原來的設置回去。如果你的請求都很大,這麼做的成本很高。


下面是一個簡短但是完整的展示程序




下面是複製並執行回種的代碼




你可以創建一個小 util 函數




然後調用它而不是使用像 ioutil.ReadAll 這樣的命令。




當然現在你已經用一個空操作替換了 r.Body.Close(),當你在 request.Body 上調用 Close 時,這個空操作什麼也不會做。這就是 thehttputil.DumpRequest 的工作方式。


4. SQL 框架

Teamwork Desk 向用戶提供 Web 應用時,需要完成的核心工作涉及很多 MySQL。我們不使用存儲過程,所以我們在 Go 中的數據層包含有一些很複雜的 SQL......感覺有些代碼都可以因爲其複雜查詢而贏得奧林匹克體操比賽的金牌。


我們開始時使用 Gorm (http://jinzhu.me/gorm/)和 它的鏈式 API 來構建我們的 SQL。你在 Gorm 中還是可以使用原始 SQL,並且把結果打包進你的結構。(值得注意的是,我們發現我們執行這項操作的次數越來越多,這可能說明我們需要重新回到使用 Gorm 的真正方法,並且保證對其善加利用,否則我們就需要尋找其他替代品了——當然這種情況也沒什麼可怕的。)


對於一些人來說,ORM 是一個挺 low 的做法(它會讓你失去控制力、理解力、以及優化查詢的可能性)。但我們只是把 Gorm 作爲構建查詢的封裝器,我們理解它給我們的輸出,我們並沒有把 Gorm 作爲一個完整的 ORM 來使用。


Gorm 允許你利用它的鏈式 API 並且把結果打包到結構中。Gorm 的很多特性可以讓你免受代碼中的手工 SQL 折磨。它還支持 Preloading,Limits,Grouping,Associations,原始 SQL,Transactions 等。


總結下,如果你正在 Go 中手寫 SQL 的話,Gorm 絕對值得關注。




5. 濫用指針

這裏主要只是針對切片(slice)來說的,使用切片作爲參數傳個一個函數。在 Go 裏,由於數組如果作爲參數會傳值,所以如果有一個很大的數組,你不想每次傳遞和賦值它的時候都要複製一下吧?沒錯,到處傳遞數組對於存儲空間來說是一個昂貴的開銷。


但是在 Go 中,99% 的時間你都在和 slice 打交道,而不是數組。Slice 可以被看做用來描述數組某些部分(經常是全部)的東西,它含有一個指向數組開始元素的指針、slice 的長度,以及 slice 的容量。


Slice 的每部分只需要 8 個字節,所以它永遠都不會超過 24 個字節,無論其下的數組有多少內容,有多大。




我們經常把 slice 指針傳給一個函數,並且誤以爲我們節省了存儲空間。




上例中,如果我們在 t 中有很多數據,我們以爲通過把數據傳給 filterTickets, 就避免了存儲空間中的大型數據拷貝。


鑑於我們現在對 slice 的理解,我們可以愉快地把這個 slice 按照值來傳遞,而不用考慮存儲空間問題。




當然,不按引用傳遞也意味着避免了錯誤地改變指針指向的問題,因爲 slice 自身就是引用類型。


6. 非命名返回的可讀性

非命名返回(Naked returns)這個名詞描述的是在 Go 語言中,你從一個函數返回時不明確說明你返回的是什麼。


在 Go 中,你 可以有命名返回值,比如 func add(a, b int) (total int) {}。我可以只用 return 而不寫 return total。在小函數中使用非命名返回是簡潔而有效的。




這裏運行的結果情況顯而易見。如果沒有 tickets,那麼就會返回 0, 0, error。如果找到了tickets, 那麼類似 120, 80, nil 這樣的東西就會被返回,這取決於 ticket count 等因素。


這裏的關鍵在於如果你在函數簽名中有命名返回值,那麼你可以使用 return(非命名返回),當調用 return 時,它會在每個命名返回值的所處狀態中爲命名返回值返回數值。


但是我們曾有一些比較大的函數。函數中任何長到你需要滾動瀏覽的非命名返回都是潛在的漏洞,對於可讀性來說也是災難。


特別是當你還有多個返回點時,千萬不要這麼做。兩種做法都不可取——無論是非命名返回還是大函數。


以下是一個假設的例子:




7. 作用域和局部變量


一個程序可能會有多個相同的變量名,只要它們的聲明在不同的詞法塊就好。例如,你可以在函數內聲明一個局部變量 x,同時再聲明一個包級的變量 x,這是在函數內部,局部變量 x 就會替代後者,這裏稱之爲 shadow,意味着在函數作用域內局部變量將包變量隱藏了。


當你利用作用域 := 用相同的名字在不同塊中聲明變量時(被稱爲 shad

ow),你可能會因爲 Go 中作用域的問題引入不爲人知的 bug。




這裏注意的的問題存在於 := 局部變量聲明和賦值之間。通常來說當你在左邊聲明新變量時 := 只會編譯。但是如果左邊有任何變量是新的話,它也會這樣運行。在上面的例子中 err 是新的,所以你期待 tickets 被覆寫,就像是已經在上面的函數返回參數中聲明瞭一樣。但是實際情況並非如此


原因在於塊作用域——一個新的被聲明的 ticket 變量被分配出去,並且一旦塊完成之後就會丟失自己的作用域。要改變這一點,只要在塊外聲明變量 err,並且使用 = 而非 := 。一個好的編輯器,比如 Emacs 或 Sublime 會解決這個 shadow 問題。




8. Map 併發訪問的崩潰問題

併發方式訪問  Map 並不安全。我們曾經發生過一個場景,我們設置了一個應用生命週期都能訪問的包級別變量的 map。這個 map 用於回收應用中每個控制器的數據。當然在 Go 中,每個 HTTP 請求都有它自身的 goroutine。


你能看出來將會發生什麼——最終不同的 goroutine 會試圖同時訪問 map,無論是讀還是寫。這會造成 panic,而我們的進程將會崩潰。(當進程停止時,我們用 Ubuntu 上的 upstart 腳本來重啓應用,至少保持讓應用“不死”。)


尋找這類 panic 原因的過程很笨重,有點像 1.6 版以前的情況,當堆棧 dump 會把所有運行的 goroutine 都包括進來時,就會產生大量需要篩查的日誌。


Go 團隊確實考慮過使 map 在併發訪問時更安全,但是最終決定放棄,因爲這會爲一般場景造成不必要的開支——這是一種讓事情保持簡單的實用的做法。


在 golang.org FAQ 中提到如此選擇的原因


“經過漫長的討論之後,我們決定,map 的一般用法並不需要來自多個 goroutine 的安全訪問,在需要的場景中,map 可能處於某些已經被同步保護的大型數據結構或計算中。所以,要求所有 map 操作獲取互斥鎖會減慢大多數程序,但是卻只爲很少的程序提供了安全性。


由於不受控制的 map 訪問會使進程崩潰,所以這並不是一個輕鬆的決定。”


我們的代碼看起來有點像這個:




我們把它變成使用 stdlib 中的 sync 包來嵌入結構中的讀取器/寫入器互斥鎖,該結構還會封裝我們的 map 。我們向結構中添加了一些 helper 方法:Add 和 Get 。




從此終於遠離崩潰了。


9. 理解 Vendor——宙斯的鬍子

好吧,承認這件事有點不好意思,我們把代碼發佈到生產環境過程中竟然沒用 vendor。


Vendor 是 Go 語言的包及依賴管理工具,如果你不知背景的話,接下來我說明下這件事爲什麼很糟糕。你通過從你項目的根目錄中運行 go get ./... 獲得依賴。這會將每一個依賴都需要從 master 上的 HEAD 拉取。


很明顯這種情況非常糟糕,除非你在服務器的 $GOPATH 上保存了所需版本的依賴,而且從來不更新(包括從來沒有重建或啓動新服務器),否則破壞性的改變不可避免,而你也對生產環境中運行的代碼失去控制。



在 Go 1.4 中我們 vendor 使用了 Godeps 及其 GOPATH 方法。


在 1.5 中我們使用了 GO15VENDOREXPERIMENT 環境變量。


在 1.6 中,謝天謝地,項目根目錄中的 /vendor 終於可以被識別爲可以存放你依賴的地方,不再需要額外工具了。你可以使用各種 vendoring 工具中的一種來追蹤版本並且更輕鬆地添加及更新依賴(移除 .git、更新 manifest 等)


學無止境

上文是我們早期犯下的一部分錯誤以及從中得到教訓的小清單。


我們是一個由 5 個開發者組成的構建 Teamwork Desk 的小團隊,但是我們在去年一年的時間裏學到了關於 Go 的大量知識,同時我們還以飛快速度交付了大量的優秀功能。今年你會看到我們出席各種 Go 技術大會,包括在丹佛舉行的 GopherCon。我很快也將在科克本地的開發者聚會上分享關於 Go 的實踐。


我們將會繼續關注 Go 開源工具發佈,並且回饋已有的庫。到目前爲止,我們向一些小項目貢獻了不算少的代碼,我們提出的性能要求也被 Stripe、Revel 以及其他開源 Go 項目所採納。


  • s3pp https://github.com/Teamwork/s3pp

  • stripehooks https://github.com/Teamwork/stripehooks

  • tnef parser https://github.com/Teamwork/tnef



我們永遠都在尋找優秀的開發者,到 Teamwork.com 上來,加入我們吧!


本文作者:Peter Kelly,Teamwork Desk 高級工程師。


英文原文:http://engineroom.teamwork.com/go-learn/

發佈了581 篇原創文章 · 獲贊 707 · 訪問量 202萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章