原文:scene-si.org/2018/07/24/…html
我寫了多年的 Go 微服務,並在寫完兩本關於 (API Foundations in Go 和 12 Factor Applications with Docker and Go) 主題的書以後,有了一些關於如何寫好 Go 代碼的想法mysql
但首先,我想給閱讀這篇文章的讀者解釋一點。好代碼是主觀的。你可能對於好代碼這一點,有徹底不一樣的想法,而咱們可能只對其中一部分意見一致。另外一方面,咱們可能都沒有錯,只是咱們從兩個角度出發,從而選擇了不一樣的方式解決工程問題,並不意味着意見不一致的不是好代碼。git
包很重要,你可能會反對 - 可是若是你在用 Go 寫微服務,你能夠將全部代碼放在一個包中。固然,下面也有一些反對的觀點:github
咱們能夠計算一下,一個微服務包的最小數量是 1。若是你有一個大型的微服務,它擁有 websocket 和 http 網關,你最終可能須要 5 個包(類型,數據存儲,服務,websocket 和 http 包)。golang
簡單的微服務實際上並不關心從數據存儲層(repository),或者從傳輸層(websocket,http)抽離業務邏輯。你能夠寫簡單的代碼,轉換數據而後響應,也是能夠運行的。可是,添加更多的包能夠解決一些問題。例如,若是你熟悉 SOLID 原則,S
表明單一職責。若是咱們拆分紅包,這些包就能夠是單一職責的。web
types
- 聲明一些結構,可能還有一些結構的別名等repository
- 數據存儲層,用來處理存儲和讀取結構service
- 服務層,包裝存儲層的具體業務邏輯實現http
, websocket
, … - 傳輸層,用來調用服務層固然,根據你使用的狀況,還能夠進一步細分,例如,可使用types/request
和 types/response
來更好的分隔一些結構。這樣就能夠擁有 request.Message
和response.Message
而不是 MessageRequest
和 MessageResponse
。若是一開始就像這樣拆分開,可能會更有意義。sql
可是,爲了強調最初的觀點 - 若是你只用了這些聲明包中的一部分,也沒什麼影響。像 Docker 這樣的大型項目在 server
包下只使用了 types
包,這是它真正須要的。它使用的其餘包(像 errors 包),多是第三方包。docker
一樣須要注意的是,在一個包中,共享正在處理的結構和函數會很容易。若是你有相互依賴的結構,將它們拆分爲兩個或多個不一樣的包可能會致使鑽石依賴問題。解決方案也很顯然 - 將代碼放到一起,或者將全部代碼放在一個包中。數據庫
到底選哪個呢?兩種方法都行。若是我非要按規則來的話,將其拆分更多的包可能會使添加新代碼變得麻煩。由於你可能要修改這些包才能添加單個 API 調用。若是不是很清楚如何佈局,那麼在包之間跳轉可能會帶來一些認知上的開銷。在不少狀況下,若是項目只有一兩個包,閱讀代碼會更容易。json
你確定也不想要太多的小包。
若是是描述性的 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文章的一句話,這是一句使人難忘的結束語:
若是你真的喜歡寫代碼,你會很是喜歡儘量少地寫代碼。
可是,必定要編寫那些單元測試。完結。