原文:https://scene-si.org/2018/07/24/writing-great-go-code/
我寫了多年的 Go 微服務,并在寫完兩本關于 (API Foundations in Go 和 12 Factor Applications with Docker and Go) 主題的書之后,有了一些關于如何寫好 Go 代碼的想法
但首先,我想給閱讀這篇文章的讀者解釋一點。好代碼是主觀的。你可能對于好代碼這一點,有完全不同的想法,而我們可能只對其中一部分意見一致。另一方面,我們可能都沒有錯,只是我們從兩個角度出發,從而選擇了不同的方式解決工程問題,并不意味著意見不一致的不是好代碼。
包
包很重要,你可能會反對 - 但是如果你在用 Go 寫微服務,你可以將所有代碼放在一個包中。當然,下面也有一些反對的觀點:
將定義的類型放入單獨的包中
維護與傳輸無關的服務層
在服務層之外,維護一個數據存儲(repository)層
我們可以計算一下,一個微服務包的最小數量是 1。如果你有一個大型的微服務,它擁有 websocket 和 http 網關,你最終可能需要 5 個包(類型,數據存儲,服務,websocket 和 http 包)。
簡單的微服務實際上并不關心從數據存儲層(repository),或者從傳輸層(websocket,http)抽離業務邏輯。你可以寫簡單的代碼,轉換數據然后響應,也是可以運行的。但是,添加更多的包可以解決一些問題。例如,如果你熟悉 ?SOLID 原則,S
代表單一職責。如果我們拆分成包,這些包就可以是單一職責的。
types
- 聲明一些結構,可能還有一些結構的別名等repository
- 數據存儲層,用來處理存儲和讀取結構service
- 服務層,包裝存儲層的具體業務邏輯實現http
,websocket
, … - 傳輸層,用來調用服務層
當然,根據你使用的情況,還可以進一步細分,例如,可以使用types/request
和 types/response
來更好的分隔一些結構。這樣就可以擁有 request.Message
和response.Message
而不是 MessageRequest
和 MessageResponse
。如果一開始就像這樣拆分開,可能會更有意義。
但是,為了強調最初的觀點 - 如果你只用了這些聲明包中的一部分,也沒什么影響。像 Docker 這樣的大型項目在 server
包下只使用了 types
?包,這是它真正需要的。它使用的其他包(像 errors 包),可能是第三方包。
同樣需要注意的是,在一個包中,共享正在處理的結構和函數會很容易。如果你有相互依賴的結構,將它們拆分為兩個或多個不同的包可能會導致鉆石依賴問題。解決方案也很顯然 - 將代碼放到一塊兒,或者將所有代碼放在一個包中。
到底選哪一個呢?兩種方法都行。如果我非要按規則來的話,將其拆分更多的包可能會使添加新代碼變得麻煩。因為你可能要修改這些包才能添加單個 API 調用。如果不是很清楚如何布局,那么在包之間跳轉可能會帶來一些認知上的開銷。在很多情況下,如果項目只有一兩個包,閱讀代碼會更容易。
你肯定也不想要太多的小包。
錯誤
如果是描述性的 Errors 可能是開發人員檢查生產問題的唯一工具。這就是為什么我們要優雅地處理錯誤,要么將它們一直傳遞到應有程序的某一層,如果錯誤無法處理,該層就接收錯誤并記錄下來,這一點非常重要。以下是標準庫錯誤類型缺少的一些特性:
錯誤信息不含堆棧跟蹤
不能堆積錯誤
errors 是預實例化的
但是,通過使用第三方錯誤包(我最喜歡的是pkg/Errors.))可以幫助解決這些問題。也有其他的第三方錯誤包,但是這個是 Dave Cheney (Go 語言大神)編寫的,它在錯誤處理的方式在一定程度上是一種標準。他的文章 Don’t just check errors, handle them gracefully 是推薦必讀的。
錯誤的堆棧跟蹤
pkg/errors
包在調用 errors.New
時,會將上下文(堆棧跟蹤)添加到新建的錯誤中。
users_test.go:34:?testing?error?Hello?world
????????github.com/crusttech/crust/rbac_test.TestUsers
????????????????/go/src/github.com/crusttech/crust/rbac/users_test.go:34
????????testing.tRunner
????????????????/usr/local/go/src/testing/testing.go:777
????????runtime.goexit
????????????????/usr/local/go/src/runtime/asm_amd64.s:2361
考慮到完整的錯誤信息是 "Hello world",使用 fmt.Printf
帶有%+v
的參數或者類似的方式來打印少量的上下文 - 對于查找錯誤的而言,是一件很棒的事。你可以確切知道是哪里創建了錯誤(關鍵字)。當然,當涉及到標準庫時,errors
包和本地 error
類型 - 不提供堆棧跟蹤。但是,使用 pkg/errors
可以很容易地添加一個。例如:
resp,?err?:=?u.Client.Post(fmt.Sprintf(resourcesCreate,?resourceID),?body)
if?err?!=?nil?{
????????return?errors.Wrap(err,?"request?failed")
}
在上面這個例子中,pkg/errors
包將上下文添加 err 中,加的錯誤消息("request failed"
) 和堆棧跟蹤都會拋出來。通過調用 errors.Wrap
來添加堆棧跟蹤,所以你可以精準追蹤到此行的錯誤。
堆積錯誤
你的文件系統,數據庫,或者其他可能拋出相對不太好描述的錯誤。例如,Mysql 可能會拋出這種強制錯誤:
ERROR?1146?(42S02):?Table?'test.no_such_table'?doesn't?exist
這不是很好處理。然而,你可以使用 errors.Wrap(err,"database aseError")
在上面堆積新的錯誤。這樣,就可以更好地處理 "databaseError"
等。pkg/errors
包將在 causer
接口后面保留實際的錯誤信息。
type?causer?interface?{
???????Cause()?error
}
這樣,錯誤堆積在一起,不會丟失任何上下文。附帶說一下,mysql 錯誤是一個類型錯誤,其背后包含的不僅僅是錯誤字符串的信息。這意味著它有可能被處理的更好:
if?driverErr,?ok?:=?err.(*mysql.MySQLError);?ok?{
????if?driverErr.Number?==?mysqlerr.ER_ACCESS_DENIED_ERROR?{
????????//?Handle?the?permission-denied?error
????}
}
此例子來自于 this stackoverflow thread。
錯誤預實例化
究竟什么是錯誤(error)呢?非常簡單,錯誤需要實現下面的接口:
type?error?interface?{
????Error()?string
}
在 net/http
的例子中,這個包將幾種錯誤類型暴露為變量,如文檔所示。在這里添加堆棧跟蹤是不可能的(Go不允許對全局 var 聲明可執行代碼,只能進行類型聲明)。其次,如果標準庫將堆棧跟蹤添加到錯誤中 - 它不會指向返回錯誤的位置,而是指向聲明變量(全局變量)的位置。
這意味著,你仍然需要在后面的代碼中強制調用類似于 ?return errors.WithStack(ErrNotSupported)
的代碼。這也不是很痛苦,但不幸的是,你不能只導入 pkg/errors
,就讓所有現有的錯誤都帶有堆棧跟蹤。如果你還沒有使用 errors.New
來實例化你的錯誤,那么它需要一些手動調用。
日志
接下來是日志,或者更恰當的說,結構化日志。這里提供了許多軟件包,類似于 sirupsen/logrus 或我最喜歡的APEX/LOG。這些包也支持將日志發送到遠程的機器或者服務,我們可以用工具來監控這些日志。
當談到標準日志包時,我不常看到的一個選項是創建一個自定義 logger,并將 log.LShorfile
或 log.LUTC
等標志傳遞給它,以再次獲得一點上下文,這能讓你的工作變輕松 - 尤其在處理不同時區的服務器時。
const?(
????????Ldate?????????=?1?<iota?????//?the?date?in?the?local?time?zone:?2009/01/23
????????Ltime?????????????????????????//?the?time?in?the?local?time?zone:?01:23:23
????????Lmicroseconds?????????????????//?microsecond?resolution:?01:23:23.123123.??assumes?Ltime.
????????Llongfile?????????????????????//?full?file?name?and?line?number:?/a/b/c/d.go:23
????????Lshortfile????????????????????//?final?file?name?element?and?line?number:?d.go:23.?overrides?Llongfile
????????LUTC??????????????????????????//?if?Ldate?or?Ltime?is?set,?use?UTC?rather?than?the?local?time?zone
????????LstdFlags?????=?Ldate?|?Ltime?//?initial?values?for?the?standard?logger
)
即使你沒有創建自定義 logger,你也可以使用 SetFlags
來修改默認 logger。(playground link):
package?main
import?(
????"log"
)
func?main()?{
????log.SetFlags(log.LstdFlags?|?log.Lshortfile)
????log.Println("Hello,?playground")
}
結果如下:
2009/11/10?23:00:00?main.go:9:?Hello,?playground
你不想知道你在哪里打印了日志嗎?這會讓跟蹤代碼變得更容易。
接口
如果你正在寫接口并命名接口中的參數,請考慮以下的代碼片段:
type?Mover?interface?{
????Move(context.Context,?string,?string)?error
}
你知道這里的參數代表什么嗎?只需要在接口中使用命名參數就可以讓它很清晰。
type?Mover?interface?{
????Move(context.Context,?source?string,?destination?string)
}
我還經常看到一些使用一個具體類型作為返回值的接口。一種未得到充分利用的做法是,根據一些已知的結構體或接口參數,以某種方式聲明接口,然后在接收器中填充結果。這可能是 Go 中最強大的接口之一。
type?Filler?interface?{
????Fill(r?*http.Request)?error
}
func?(s?*YourStruct)?Fill(r?*http.Request)?error?{
????//?here?you?write?your?code...
}
更可能的是,一個或多個結構體可以實現該接口。如下:
type?RequestParser?interface?{
????Parse(r?*http.Request)?(*types.ServiceRequest,?error)
}
此接口返回具體類型(而不是接口)。通常,這樣的代碼會使你代碼庫中的接口變得雜亂無章,因為每個接口只有一個實現,并且在你的應用包結構之外會變得不可用。
小帖士
如果你希望在編譯時確保你的結構體符合并完全實現一個接口(或多個接口),你可以這么做:
var?_?io.Reader?=?&YourStruct{}
var?_?fmt.Stringer?=?&YourStruct{}
如果你缺少這些接口所需的某些函數,編譯器就會報錯。字符 _
表示丟棄變量,所以沒有副作用,編譯器完全優化了這些代碼,會忽視這些被丟棄的行。
空接口
與上面的觀點相比,這可能是更有爭議的觀點 - 但是我覺得使用 interface{}
有時非常有效。在 HTTP API 響應的例子中,最后一步通常是 json 編碼,它接收一個接口參數:
func?(enc?*Encoder)?Encode(v?interface{})?error
因此,完全可以避免將 API 響應設置成具體類型。我并不建議對所有情況都這么處理,但是在某些情況下,可以在 API 中完全忽略響應的具體類型,或者至少說明具體類型聲明的意義。腦海中浮現的一個例子是使用匿名結構體。
body?:=?struct?{
????Username?string???`json:"username"`
????Roles????[]string?`json:"roles,omitempty"`
}{username,?roles}
首先,不使用 interface{}
的話,無法從函數里返回這種結構體。顯然,json 編碼器可以接受任何類型的內容,因此,按傳遞空接口(對我來說)是完全有意義的。雖然趨勢是聲明具體類型,但有時候你可能不需要一層中間層。對于包含某些邏輯并可能返回各種形式的匿名結構體的函數,空接口也很合適。
更正:匿名結構體不是不可能返回,只是做起來很麻煩:playground
感謝 @Ikearens at Discord Gophers #golang channel
第二個用例是數據庫驅動的 API 設計,我之前寫過一些有關內容,我想指出的是,實現一個完全由數據庫驅動的 API 是非常可能的。這也意味著添加和修改字段是僅僅在數據庫中完成的,而不會以 ORM 的形式添加額外的間接層。顯然,你仍然需要聲明類型才能在數據庫中插入數據,但是從數據庫中讀取數據可以省略聲明。
//?getThread?fetches?comments?by?data,?order?by?ID
func?(api?*API)?getThread(params?*CommentListThread)?(comments?[]interface{},?err?error)?{
????//?calculate?pagination?parameters
????start?:=?params.PageNumber?*?params.PageSize
????length?:=?params.PageSize
????query?:=?fmt.Sprintf("select?*?from?comments?where?news_id=??and?self_id=??and?visible=1?and?deleted=0?order?by?id?%s?limit?%d,?%d",?params.Order,?start,?length)
????err?=?api.db.Select(&comments,?query,?params.NewsID,?params.SelfID)
????return
}
同樣,你的應用程序可能充當反向代理,或者只使用無模式(schema-less)的數據庫存儲。在這些情況下,目的只是傳遞數據。
一個大警告(這是你需要輸入結構體的地方)是,修改 Go 中的接口值并不是一件容易的事。你必須將它們強制轉換為各種內容,如 map、slice 或結構體,以便可以在訪問這些返回的數據。如果你不能保持結構體一成不變,而只是將它從 DB(或其他后端服務)傳遞到 JSON 編碼器(會涉及到斷言成具體類型),那么顯然這個模式不適合你。這種情況下不應該存在這樣的空接口代碼。也就是說,當你不想了解任何關于載荷的信息時,空接口就是你需要的。
代碼生成
盡可能使用代碼生成。如果你想生成用于測試的 mock,如果你想生成 proc/GRPC 代碼,或者你可能擁有的任何類型的代碼生成,可以直接生成代碼并提交。在發生沖突的情況下,可以隨時將其丟棄,然后重新生成。
唯一可能的例外是提交類似于 public_html
文件夾的內容,其中包含你將使用 rakyll/statik 打包的內容。如果有人想告訴我,由 gomock 生成的代碼在每次提交時都會以兆字節的數據污染 GIT 歷史記錄?不會的。
結束語
關于 Go 的最佳實踐和最差實踐的另一本值得注意的好書應該是Idiomatic Go。
如果你不熟悉的話,可以閱讀一下 - 它是與本文很好的搭配。
我想在這里引用Jeff Atwood post - The Best Code is No Code At All文章的一句話,這是一句令人難忘的結束語:
如果你真的喜歡寫代碼,你會非常喜歡盡可能少地寫代碼。
但是,一定要編寫那些單元測試。完結。