[譯] 如何寫好 Go 代碼

原文:scene-si.org/2018/07/24/…html

我寫了多年的 Go 微服務,並在寫完兩本關於 (API Foundations in Go12 Factor Applications with Docker and Go) 主題的書以後,有了一些關於如何寫好 Go 代碼的想法mysql

但首先,我想給閱讀這篇文章的讀者解釋一點。好代碼是主觀的。你可能對於好代碼這一點,有徹底不一樣的想法,而咱們可能只對其中一部分意見一致。另外一方面,咱們可能都沒有錯,只是咱們從兩個角度出發,從而選擇了不一樣的方式解決工程問題,並不意味着意見不一致的不是好代碼。git

包很重要,你可能會反對 - 可是若是你在用 Go 寫微服務,你能夠將全部代碼放在一個包中。固然,下面也有一些反對的觀點:github

  1. 將定義的類型放入單獨的包中
  2. 維護與傳輸無關的服務層
  3. 在服務層以外,維護一個數據存儲(repository)層

咱們能夠計算一下,一個微服務包的最小數量是 1。若是你有一個大型的微服務,它擁有 websocket 和 http 網關,你最終可能須要 5 個包(類型,數據存儲,服務,websocket 和 http 包)。golang

簡單的微服務實際上並不關心從數據存儲層(repository),或者從傳輸層(websocket,http)抽離業務邏輯。你能夠寫簡單的代碼,轉換數據而後響應,也是能夠運行的。可是,添加更多的包能夠解決一些問題。例如,若是你熟悉 SOLID 原則,S 表明單一職責。若是咱們拆分紅包,這些包就能夠是單一職責的。web

  • types - 聲明一些結構,可能還有一些結構的別名等
  • repository - 數據存儲層,用來處理存儲和讀取結構
  • service - 服務層,包裝存儲層的具體業務邏輯實現
  • http, websocket, … - 傳輸層,用來調用服務層

固然,根據你使用的狀況,還能夠進一步細分,例如,可使用types/requesttypes/response 來更好的分隔一些結構。這樣就能夠擁有 request.Messageresponse.Message 而不是 MessageRequestMessageResponse。若是一開始就像這樣拆分開,可能會更有意義。sql

可是,爲了強調最初的觀點 - 若是你只用了這些聲明包中的一部分,也沒什麼影響。像 Docker 這樣的大型項目在 server 包下只使用了 types 包,這是它真正須要的。它使用的其餘包(像 errors 包),多是第三方包。docker

一樣須要注意的是,在一個包中,共享正在處理的結構和函數會很容易。若是你有相互依賴的結構,將它們拆分爲兩個或多個不一樣的包可能會致使鑽石依賴問題。解決方案也很顯然 - 將代碼放到一起,或者將全部代碼放在一個包中。數據庫

到底選哪個呢?兩種方法都行。若是我非要按規則來的話,將其拆分更多的包可能會使添加新代碼變得麻煩。由於你可能要修改這些包才能添加單個 API 調用。若是不是很清楚如何佈局,那麼在包之間跳轉可能會帶來一些認知上的開銷。在不少狀況下,若是項目只有一兩個包,閱讀代碼會更容易。json

你確定也不想要太多的小包。

錯誤

若是是描述性的 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.LShorfilelog.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

第二個用例是數據庫驅動的 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文章的一句話,這是一句使人難忘的結束語:

若是你真的喜歡寫代碼,你會很是喜歡儘量少地寫代碼。

可是,必定要編寫那些單元測試。完結

相關文章
相關標籤/搜索