閱讀了Dave Cheney 關於go編碼的博客:Practical Go: Real world advice for writing maintainable Go programshtml
實際應用下來,對我這個go入門者,提高效果顯著。git
我對做者的文章進行整理翻譯,提取精煉,加上本身的理解,分享出來。但願也能給你們帶來幫助。程序員
但願你們支持原做者,原汁原味的內容能夠點擊 連接 閱讀。文中部分例子爲我的添加,若有不足敬請包容指出^ _ ^github
(PS:如涉及侵權,請與我聯繫,我會及時刪除文章,知識傳播無界,望你們支持)golang
我的認爲,編碼的最佳實踐本質是爲了提升代碼的迭代產能,減小bug的概率。(成本、效率、穩定)算法
做者Dave Cheney提到,go語言的最佳實踐的指導原則,須要考慮3點:sql
簡潔是對於人而言的,若是代碼很複雜,甚至違法人的慣性理解,那麼修改和維護是牽一髮而動全身的。數據庫
由於代碼被閱讀的次數遠遠多於被修改的次數。在做者看來,代碼被人的閱讀和修改的需求,比被機器執行的需求更強烈。go編碼最佳實踐第一步就應該肯定代碼的可讀性。編程
在我我的看來,相似於一致性算法中, raft爲何比paxos傳播和應用更廣,一個很重要的緣由就是raft更加易於理解,raft做者在論文中也提到,raft設計的最重要的初衷就是,paxos太難懂了。可讀性的重要性應該排在首位的。json
良好的編碼習慣,能夠提升代碼的交流效率。使得同事們看到代碼就知道實現了什麼,而沒必要去逐行閱讀,大大節約了時間,提升開發效率。
此外,對於go語言自己而言,不管在編譯速度仍是debug時間花費上,go相對C++也是開發效率大大提升的。
命名對編寫可讀性好的go程序相當重要!
曾經聽到這樣的一個言論:對變量的命名要像給本身孩子起名同樣慎重。
其實,不光是變量命名,還包括function、method、type、package等,命名都很重要。
就像編碼不是爲了在儘可能短的行數內,寫完程序。而是爲了寫出可讀性高的程序。
一樣的,咱們的命名標識也不是越短越好,而是容易被他人理解。
一個好名字應該具有的特色:
簡短:一個好名字應該在具有高辨識度的狀況下,儘可能簡短。
judgeAuth
(容易歧義),judgeUserLoginAuthority
(冗長)judgeLoginAuth
描述性的:一個好的名字應該是描述變量和常量的用途,而非他們的內容;描述function的結果,或者method的行爲,而不是他們的操做;描述package的目的,而非包含的內容。描述的準確性衡量了名字的好壞。
leader_operation
,好的名字election
ReturnElection
,好的名字NewElection
ElectionState
,好的名字Role
可預測的:一個好的名字,僅經過名字,你們就能夠推斷他們的用途。應該遵循你們的慣用理解。下面會詳細闡述。好比
i,j,k
經常使用來在迭代中描述引用計數值n
一般用來表示計數累加值v
一般表示一個編碼函數的值k
一般用在map中的keys
一般用來表示字符串關於名字的長度,咱們有這些建議:
舉一個做者文中的例子說明:
type Person struct { Name string Age int } // AverageAge returns the average age of people. func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count }
在這個例子中,people 距離最後一次使用間隔7行,而變量p是用來迭代perple的,p距離最後一次使用間隔1行。因此p可使用1個字母命名,而people則使用單詞來命名。
其實這裏是防止人們閱讀代碼時,閱讀過多行數後,忽然發現一個上下文不理解的詞,再去找定義,致使可讀性差。
同時,注意例子中的空行的使用。一個是函數之間的空行,另外一個是函數內的空行:在函數裏幹了3件事:異常判斷;累加age;返回。在這3者之間添加空行,能夠增長可讀性。
以上強調的原則須要在上下文中去實際判斷才行,萬事無絕對。
func (s *SNMP) Fetch(oid []int, index int) (int, error)
與
func (s *SNMP) Fetch(o []int, i int) (int, error)
相比,顯然使用oid命名更具有可讀性,而使用短變量o則不容易理解。
由於golang 是一個強類型的語言,在變量的命名中包含類型是信息冗餘的,並且容易致使誤解錯誤。舉個做者的例子:
var usersMap map[string]*User
咱們將一個從string 到 User 的map結構,命名爲UsersMap,看起來合情合理,可是變量的類型中已經包含了map,沒有必要再在變量中註明了。
做者的話來說:若是Users 描述不清楚,nameUsersMap也不見得多清楚。
對於函數的名稱一樣適用,好比:
type Config struct { // } func WriteConfig(w io.Writer, config *Config)
config 的名稱有冗餘了,類型中已經說明它是一個*Config了,若是變量在函數中最後一次引用的距離足夠短,那麼適用簡稱c或者conf 會更簡潔。
提示:不要讓包名搶佔了好的變量名。好比context這個包,若是使用func WriteLog(context context.Context, message string)
,那麼編譯的時候會報錯,由於包名和變量名衝突了。因此通常使用的時候,會使用func WriteLog(ctx context.Context, message string)
儘可能不要將常見的變量名,換成其餘的意思,這樣會形成讀者的歧義。
並且對於代碼中一個類型的變量,不要屢次改換它的名字,儘可能使用一個名字。好比對於數據庫處理的變量,不要每次出現不一樣的名字,好比d *sql.DB
,dbase *sql.DB
,DB *sql.DB
,最好使用慣用的,一致的名字db *sql.DB
。這樣你在其餘的代碼中,看到變量db時,也能推測到它是*sql.DB
還有一些慣用的短變量名字,這裏提一下:
i, j, k
用做循環中的索引n
用在計數和累加v
表示值k
表示一個map或者slice 的keys
表示字符串對於一個變量的聲明有多重聲明類型:
var x int = 1
var x = 1
var x int;x=1
var x = int(1)
x:=1
在做者看來,這是go的設計者犯的錯誤,可是來不及改正了,新的版本要保持向前兼容。有這麼多種聲明的方式,咱們怎麼選擇本身的類型呢。
做者給出了這些建議:
var
。var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing)
var
每每表示這是這個類型的空值。
:=
var things []Ting = make([]Thing, 0)
vs
var things = make([]Thing, 0)
vs
things := make([]Thing, 0)
對於go來講,= 右側的類型,就是=左側的類型,上面三個例子中,最後一個使用:=
的例子,既能充分標識類型,又足夠簡潔。
編程生涯大部分時間都是和做爲團隊的一員,參與其中。做者建議你們最好保持團隊原來的編碼風格,即便那不是你偏心的風格。要不人會致使整個工程風格不一致,這會更糟糕。
註釋很重要,註釋應該作到如下3點之一:
舉個例子
這是適合對外方法的註釋,解釋了作了什麼,怎麼作的
/ Open opens the named file for reading. // If successful, methods on the returned file can be used for reading. The second form is ideal for commentary inside a method:
這是適合方法內的註釋,解釋了作了什麼
// queue all dependant actions var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) }
解釋爲何的註釋比較少見,可是也是必要的,好比如下:
return &v2.Cluster_CommonLbConfig{ // Disable HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, }
將value 設置成0的做用並很差理解,增長註釋大大增長可理解性。
在上文中提到,變量和常量的名字又應該描述他們的目的。然而他們的註釋最好描述他們的內容。
const randomNumber = 6 // determined from an unbiased die
在這個例子中,註釋描述了爲何randomNumber
被賦值爲6,註釋沒有描述在哪裏randomNumer
會被使用。再看一些例子:
const ( StatusContinue = 100 // RFC 7231, 6.2.1 StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2 StatusProcessing = 102 // RFC 2518, 10.1 StatusOK = 200 // RFC 7231, 6.3.1
這裏區分一下,內容表示100表明什麼,表明RFC 7231,可是100的目的是表示StatusContinue。
提示,對於沒有初始值的變量,註釋應該描述誰來初始化這些變量
// sizeCalculationDisabled indicates whether it is safe // to calculate Types' widths and alignments. See dowidth. var sizeCalculationDisabled bool
由於dodoc 是你的項目package的文檔,因此你應該在每一個公共的名稱上添加註釋,包括變量,常量,函數,方法。
這裏給出兩個谷歌風格指南的準則:
舉個例子:
package ioutil // ReadAll reads from r until an error or EOF and returns the data it read. // A successful call returns err == nil, not err == EOF. Because ReadAll is // defined to read from src until EOF, it does not treat an EOF from Read // as an error to be reported. func ReadAll(r io.Reader) ([]byte, error)
這個規則有一個例外,無需對實現接口的方法添加文檔註釋,好比不要這麼作:
// Read implements the io.Reader interface func (r *FileReader) Read(buf []byte) (int, error)
這裏給出一個io
包的完整例子:
// LimitReader returns a Reader that reads from r // but stops with EOF after n bytes. // The underlying implementation is a *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // A LimitedReader reads from R but limits the amount of // data returned to just N bytes. Each call to Read // updates N to reflect the new amount remaining. // Read returns EOF when N <= 0 or when the underlying R returns EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if l.N <= 0 { return 0, EOF } if int64(len(p)) > l.N { p = p[0:l.N] } n, err = l.R.Read(p) l.N -= int64(n) return }
提示:在寫函數的內容前,最好先把函數的註釋寫出
若是遇到了不完善的代碼,應該記錄一個issue,以便後續去修復。
傳統的方法是在代碼上記錄一個todo,以便提醒。好比
// TODO(dfc) this is O(N^2), find a faster way to do this.
好的代碼自己就是註釋。若是要在一段代碼上添加註釋,要問問本身,可否優化這段代碼,而不用添加註釋。
函數應該只作一件事,若是你發現要在這個函數的註釋裏,提到其餘函數,那麼該想一想拆解這個冗餘的函數。
此外,函數越精簡,越便於測試。並且函數名自己就是最好的註釋。
每一個go 的package 實際上都是本身的小型go程序。就比如一個function或者method的實現對調用者無關同樣,包內的對外暴露的function,method和類型的實現,和調用者無關。
一個好的go長鬚應該努力下降耦合度,這樣隨着項目的演化,一個package的變化不會影響到整個程序的其餘package。
接下來會討論如何設計一個package,包括名字,類型,和編寫method和funciton的一些技巧。
package 的名字應該儘可能簡短,最好用一個單詞表示。考慮package名字的時候,不要想着我要在package內寫哪些類型,而是想着這個package要提供哪些服務。要以package提供哪些服務命名。
一個項目那的package名字應該都是不一樣的。若是你發現可能要取相同的pcakge名字,那麼多是如下緣由:
base
,common
,util
若是package內包含了一些列不相關的function,那麼很難說明這個package提供了哪些服務。這經常會致使package名字取一些通用的名字,相似utilities
。
大的項目中,常常會出現像utils
或者helpers
這樣的package名字。它們每每在依賴的最底層,以免循環導入問題。可是這樣也致使出現一些通用的包名稱,而且體現不出包的用意。
做者的建議是將utils
和helpers
這樣的package名字取取消掉:分析函數被調用的場景,若是可能的話,將函數轉移到調用者的package內,即便這涉及一些代碼的拷貝。
提示:代碼重複,比錯誤的抽象,代價更低提示:使用單詞的複數命名通用的包。好比
strings
包含了string處理的通用函數。
咱們應該儘量的減小package的數量,好比如今有三個包common
、client
,server
,咱們能夠將其組合爲一個包het/http
,用client.go和server.go來區分client和server,避免引入過多的冗餘包。
提示,標識符的名字包含了包名,好比net/http
的GET
function,調用的使用寫做http.Get
,在標識符起名和package起名時要考慮這一點
go語言沒有try
和catch
來作exception處理。每每經過return一個錯誤來進行錯誤處理。若是錯誤返回在程序底部,閱讀代碼的人每每要在大腦裏記住不少邏輯情形判斷,不清晰明瞭。
來看一個例子
func (b *Buffer) UnreadRune() error { if b.lastRead > opInvalid { if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") }
對比
func (b *Buffer) UnreadRune() error { if b.lastRead <= opInvalid { return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil }
前者要閱讀一些邏輯處理,最後return 錯誤。後者首先將錯誤場景明確,並return。顯而後者更加易讀。
若是一個變量聲明,可是不給定初始值,則會被自動賦值爲空值。若是充分利用這些默認的空值,可讓代碼更加精簡。
好比對於sync.Mutex,默認值是sync.Mutex{}。咱們能夠不給定初始值,直接利用:
type MyInt struct { mu sync.Mutex val int } func main() { var i MyInt // i.mu is usable without explicit initialisation. i.mu.Lock() i.val++ i.mu.Unlock() }
一樣的,由於slice的append是返回一個新的slice,因此咱們能夠向一個nil slice直接append:
func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) }
書寫可維護的程序關鍵是保持鬆耦合--對一個package的更改,不該該影響到其餘不直接依賴這個package的其餘package。
有兩個保持所耦合的方法:
在go程序中,變量聲明能夠在function或者method做用域內,也能夠在package做用域內。若是一個變量是public變量,而且首字母大寫,那麼全部包均可以訪問到這個變量。
可變的全局變量會致使程序之間,各個獨立部分緊耦合。它對程序中的每一個function都是不可見的參數。若是變量類型人爲改變,或者被其餘函數改變,那麼任何依賴這個變量的函數都會崩潰。
若是你想減小全局變量帶來的耦合:
由於go語言中表述可見性的方法是用首字母區分,大寫表示可見,小寫表示不可見。若是一個標識符是可見的,那麼它能夠被任何任何其餘的package使用。
鑑於此,怎麼才能避免過於複雜的package依賴結構?
提示:除了cmd/
和internal
以外的每一個package,都應該包含一些源碼。
做者的建議是,使用盡量少的package,儘量大的package。你們的默認行爲應該是不建立新的pcakge,若是建立過多的package,這會致使不少類型是public的。接下來會闡述更多的細節。
若是你在這樣的規則設計package:以提供調用者什麼服務來安排。那麼是應該在一個package中的不一樣的file也如此設計呢?這裏給出一些建議:
.go
文件。好比package http
應該在一個http目錄下的http.go
文件中定義message.go
包含Request
和Response
類型。client.go
包含Client
類型,server.go
包含Server
類型。import
聲明,嘗試合併他們,或者將他們的區別找出來,而且移動到新的包中。message.go
應該負責HTTP序列化請求和響應。http.go
應該包含底層的網絡處理邏輯,client.go
和server.go
實現了HTTP業務邏輯,請求路由等。提示:以名詞命名文件名提示:go編譯器並行編譯不一樣的package,以及package不一樣的medhod和function。因此改變package內的函數位置不影響編譯時間。
go工具支持使用testing
pacakge在兩個地方寫測試用例。假設你的包叫作http2
,那麼你能夠增長一個http2_test.go
文件,使用package http2
。這樣測試用例和代碼在同一個package內,這稱爲內部測試。
go工具也支持一個特別的package聲明:以test
結尾的包名字好比package http_test
。這容許你的測試用例文件與代碼文件在同一個package目錄下,然而編譯時,這些測試用例並不會做爲你的package代碼的一部分。他們存在於本身的package內。這叫作外部測試。
當編寫單元測試時,做者推薦使用內部測試。內部測試可讓你直接測試function或者method。
然而,應該將Example
測試用例放到外部測試文件中。這樣當讀者閱讀godoc時,這些例子具有包前綴的標識,還易於拷貝。
提示:以上的建議有一些例外,如
net/http
,http並不表示是net的子包,若是你設計了一個這種package的層級結構,存在目錄內不包含任何的.go文件,那麼以上的建議不適用。
internal
包減小對外暴露的公共API若是你的項目中包含了多個package,而且有一些函數被其餘package使用,可是並不想將這些函數做爲對外項目的公共API,那麼可使用internal/
。將代碼放到此目錄下,可使得首字母大寫的function只對本項目內公開調用,不對其餘項目公開。
舉例來講,/a/b/c/internal/d/e/f
的目錄結構,c做爲一個項目,internal
目錄下的包只能被/a/b/c
import,不能被其餘層級項目import:如/a/b/g
main
函數以及main
包應該儘可能精簡。由於在項目中只有一個main
包,同時程序只可能在main.main
或者main.init
被調用一次。這致使在main.mian
中很難編寫測試用例。應該將業務邏輯移動到其餘的package中
提示:
main
應該解析參數,打開數據庫鏈接,初始化logger等,將執行邏輯轉移到其餘package。
若是在簡單的場景,API被使用都很困難,那麼API的調用將會很複雜。若是API的調用很複雜,那麼它將會難以閱讀,而且容易被忽視。
給定兩個或者更多相同類型的參數的函數,每每看起來很簡單,可是不容易使用。舉例:
func Max(a, b int) int func CopyFile(to, from string) error
這二者的區別是什麼呢?本命想第一個比較兩個數的最大值,第二個將一個文件進行拷貝,可是這不是最重要的事情。
Max(8, 10) // 10 Max(10, 8) // 10
Max 的參數是能夠交換位置的。不會引發歧義。
然而,對於CopyFile
則不一樣。
CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup")
這二者究竟是從哪一個文件複製到哪一個文件呢。這很容易帶來混淆和歧義。
一個可行的解決辦法是引入一個輔助類型,增長此method:
type Source string func (src Source) CopyTo(dest string) error { return CopyFile(dest, string(src)) } func main() { var from Source = "presentation.md" from.CopyTo("/tmp/backup") }
在上述的解決方法中,CopyTo
總會被正確的使用,不會帶來歧義。
提示:帶有多個同類型多參數的API很難被正確的使用。
若是你的API沒必要要求調用方提他們不關注的參數,那麼API將會更加的易於理解。
nil
做爲參數若是用戶不須要關注API的某個參數值,可使用nil做爲默認參數。這裏給出一個net/http
package的例子:
package http // ListenAndServe listens on the TCP network address addr and then calls // Serve with handler to handle requests on incoming connections. // Accepted connections are configured to enable TCP keep-alives. // // The handler is typically nil, in which case the DefaultServeMux is used. // // ListenAndServe always returns a non-nil error. func ListenAndServe(addr string, handler Handler) error {
ListenAndServe
有兩個參數,一個是監聽的地址,http.Handler
用來處理HTTP請求。Serve
容許第二個參數是nil
,若是傳入nil
,意味着使用的是默認的http.DefaultServeMux
做爲參數。
Serve
的調用者有兩種方式實現相同的事情。
http.ListenAndServe("0.0.0.0:8080", nil) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
在ListenAndServe
實現以下:
func ListenAndServe(addr string, handler Handler) error { l, err := net.Listen("tcp", addr) if err != nil { return err } defer l.Close() return Serve(l, handler) }
能夠想象在Server(l, handler)
中,會有if handler is nil``,使用
DefaultServeMux`
的邏輯。可是,以下的調用會致使panic:
http.Serve(nil, nil)
提示:不用將可爲nil和不可爲nil的參數放到一個函數的參數中。
http.ListenAndServe
的做者想讓在通常狀況下,用戶理解更加簡單,可是可能會致使使用上的不安全。
在代碼行數上,顯示的使用DefaultServeMux
仍是隱式的使用nil
並無多大區別。
const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", nil)
對比
const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
帶來使用上的歧義值得換來使用上的一行省略嗎?
const root = http.Dir("/htdocs") mux := http.NewServeMux() http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux)
提示:慎重考慮輔助函數給程序員節省的時間到底有多少。清晰比簡潔更重要。
將slice 做爲做爲一個函數的參數很常見。
func ShutdownVms(ids []string) error
將slice做爲一個函數的參數有一個前提,就是假定大多數時候,函數的參數有多個值。可是實際上,做者發現大多數時候,函數的參數只有一個值,這時候每每要講單個參數封裝成slice,知足函數的參數格式。
此外,由於ids
參數是一個slice,能夠將一個空slice或者nil做爲參數,編譯的時候也不會報錯。而在單測時,你也要考慮到這種場景。
再給出一個例子,若是須要判斷一些參數非0,能夠經過如下的方式:
if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { // apply the non zero parameters }
這使得if
語句特別長。有一種優化的方法:
// anyPostive indicates if any value is greater than zero. func anyPositive(values ...int) bool { for _, v := range values { if v > 0 { return true } } return false }
這看起來簡潔了不少。可是也存在一個問題,若是不給任何的參數,那麼anyPositive
會返回true,這不符合預期。
若是咱們更改參數的形式,讓調用者清楚至少應該傳入一個參數,那麼就會好不少,好比:
// anyPostive indicates if any value is greater than zero. func anyPositive(first int, rest ...int) bool { if first > 0 { return true } for _, v := range rest { if v > 0 { return true } } return false }
若是須要將一個數據結構寫到磁盤中。能夠像以下這麼寫:
// Save writes the contents of doc to the file f. func Save(f *os.File, doc *Document) error
可是上述的例子存在一些問題:函數名字叫作Save
明確了是持久化到硬盤,可是若是後續有需求要持久化到其餘主機的磁盤上,那麼還須要改函數名字,而且告知全部的調用者。
由於它將內容寫到了磁盤上,Save
函數也不便於測試。爲了校驗行爲的正確性,自測用例不得不讀取文件。
咱們也須要卻道f
是寫到了一個車臨時的目錄,而且每次都會被清理。
*os.File
也包含了不少方法,並不都是與Save
相關的。
如何優化呢?
// Save writes the contents of doc to the supplied // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error
使用io.ReadWriteCloser
接口能夠更通用的描述函數的做用。並且拓展了Save
的功能。
當調用者保存到本地磁盤時,接口實現傳入*os.File
能夠更明確的標識調用者的意圖。
如何進一步優化呢?
首先,若是Save
遵循單一職責原則,那麼它本身沒法讀取文件去驗證內容,校驗將由其餘代碼進行。
// Save writes the contents of doc to the supplied // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error
因此咱們能夠縮小傳入接口的方法範圍,只進行寫入和關閉文件。
其次,Save
的接口提供了關閉數據流的方法。那麼就要考慮何時使用WC
關閉文件:也許Save
會無條件的關閉,或者在寫入成功時關閉。
這帶來一個問題:對於Save
的調用者來講,也謝寫入成功數據以後,調用者還想繼續追加內容。
// Save writes the contents of doc to the supplied // Writer. func Save(w io.Writer, doc *Document) error
一個更好的解決方法是重寫Save
,只提供io.Writer
,只進行文件的寫入。
進行一系列優化後,Save
的做用很明確,能夠保存數據到實現接口io.Writer
的地方。這既帶來可拓展性,也減小了歧義:它只用來保存,不進行數據流的關閉以及讀取操做。
做者在他的博客中已經寫過了錯誤處理:
此處只補充一些博客中不涉及的內容。
比提示錯誤處理更好的是,不須要進行錯誤處理。(改進代碼以便沒必要進行錯誤處理)
這一部分做者從John Ousterhout的近期的書籍《A philosophy of Software Design》中得到啓發。這本書中有一張叫作「定義不復存在的錯誤」(Define Errors Out of Existence),這裏會應用到go語言中。
讓咱們寫一個同機文件行數的代碼
func CountLines(r io.Reader) (int, error) { var ( br = bufio.NewReader(r) lines int err error ) for { _, err = br.ReadString('\n') lines++ if err != nil { break } } if err != io.EOF { return 0, err } return lines, nil }
根據以前的建議,函數的入參使用的是接口io.Reader
而不是*File
。這個函數的功能是統計io.Reader讀入的內容。
這個函數使用ReadString函數統計是否到結尾,而且累加。可是因爲引入了錯誤處理,看起來有一些奇怪:
_, err = br.ReadString('\n') lines++ if err != nil { break }
之因此這樣書寫,是由於ReadString函數當遇到結尾時會返回error。
咱們能夠這樣改進:
func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() { lines++ } return lines, sc.Err() }
改進的版本使用bufio.Scaner
替換了bufio.Reader
,這替改進了錯誤處理。
若是掃描器檢查到了文本的一行,sc.Scan()
返回true
,若是檢測不到或遇到其餘錯誤,則返回false。而不是返回error。這簡化了錯誤處理。而且咱們能夠將錯誤放到sc.Err()
中進行返回。
來看一個處理http返回值得例子:
type Header struct { Key, Value string } type Status struct { Code int Reason string } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err }
在WriteResponse
函數中,有不少的錯誤處理過程,這看起來十分重複繁瑣。來看一個改進方法:
type errWriter struct { io.Writer err error } func (e *errWriter) Write(buf []byte) (int, error) { if e.err != nil { return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err }
在上述的改進函數中,咱們定義了一個新的結構errWriter
,它包含了io.Writer
,而且有本身的Write函數。當須要向response寫入數據時,調用新定義的結構。而新結構中處理了error的狀況,這樣就沒必要每次在WriteResponse
中顯示的處理err。
(個人思考是,這樣雖然簡化了err處理,可是這樣增長了讀者的閱讀負擔。並不能說是一種簡化)
一個錯誤的返回只應該被處理一次,若是想互聯錯誤則能夠不去處理它:
// WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) }
WriteAll
的錯咱們就進行了忽略。
若是對一個錯誤進行了屢次處理,是很差的,好比:
func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err) // annotated error goes to log file return err // unannotated error returned to caller } return nil }
在上述的例子中,當w.Write
發生錯誤時,咱們將其計入了log,可是卻仍然把錯誤返回了。能夠想象,在調用WriteAll
的函數中,也會進行計入log,而且返回err。這致使不少榮譽的log被計入。它的調用者可能進行以下行爲:
func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) return err } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil }
若是寫入錯誤,最後日誌中的內容是:
unable to write: io.EOF could not write config: io.EOF
可是在WriteConfig
的調用中看來,發生了錯誤,可是卻沒有任何上下文信息:
err := WriteConfig(f, &conf) fmt.Println(err) // io.EOF
咱們可使用fmt.Errorf
爲錯誤信息增長上下問:
func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil { return fmt.Errorf("could not write config: %v", err) } return nil } func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { return fmt.Errorf("write failed: %v", err) } return nil }
這樣既不會重複增長log,也能夠保留錯誤的上下文信息。
使用fmt.Errorf
來註解錯誤信息看起來很好,可是它也有一個缺點,它掩蓋了原始的錯誤信息。做者認爲將錯誤本來的返回對於鬆耦合的項目很重要。這有兩種狀況,錯誤的原始類型纔可有可無:
nil
可是有一些場景你須要保留原始的錯誤信息。這種狀況下你可使用erros包:
func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil { return nil, errors.Wrap(err, "read failed") } return buf, nil } func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config") } func main() { _, err := ReadConfig() if err != nil { fmt.Println(err) os.Exit(1) } }
這樣錯誤信息會是以下的內容:
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
並且能夠保留錯誤的原始類型:
func main() { _, err := ReadConfig() if err != nil { fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", err) os.Exit(1) } }
能夠獲得以下信息:
original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory stack trace: open /Users/dfc/.settings.xml: no such file or directory open failed main.ReadFile /Users/dfc/devel/practical-go/src/errors/readfile2.go:16 main.ReadConfig /Users/dfc/devel/practical-go/src/errors/readfile2.go:29 main.main /Users/dfc/devel/practical-go/src/errors/readfile2.go:35 runtime.main /Users/dfc/go/src/runtime/proc.go:201 runtime.goexit /Users/dfc/go/src/runtime/asm_amd64.s:1333 could not read config
使用errrors
包既能夠知足閱讀者的需求,封裝錯誤信息的上下文,又能夠知足程序判斷error原始類型的需求。
不少項選擇go語言是由於它的併發特性。go團隊不遺餘力讓併發實現更加低成本。可是使用go的併發也存在一些陷阱,下面介紹如何避開這些陷阱。
這個程序看起來有什麼問題:
package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { } }
這個一個簡單的實現http 服務的程序,可是它也作了一些其餘的事情:它在結尾的地方for死循環,這浪費了cpu,並且for內沒有使用管道等通訊機制,它將main處於阻塞狀態。沒法正常退出。
由於go runtime是協程方式調度,這個程序將會在單個cpu上無效的運行,而且可能最終致使運行鎖(兩個程序互相響應彼此,一直無效運行)。
如何修復這個問題,是如下這樣嗎:
package main import ( "fmt" "log" "net/http" "runtime" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { runtime.Gosched() } }
這看起來也有一些愚蠢,這表明沒有真正理解問題的所在。
(Goshed()是指讓出cpu時間片,讓其餘goroutine運行)
若是你對go有必定的編碼經驗,你可能會寫出這樣的程序:
package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() select {} }
使用select避免了浪費cpu,可是並無解決根本問題。
解決的方法是不要在協程中運行http.ListenAndServe()
,而是在main.main goroutine中運行。
package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }
在http.ListenAndServer
中有實現了阻塞。做者提到許多的go程序員過分使用了go併發,適度纔是關鍵。
這裏插入一下本身的理解:
通常在程序的退出處理上,要進行阻塞,並監聽相關信號(錯誤信息,退出消息,信號:sigkill/sigterm),通常select和channel 來配合使用。這裏http.ListenAndServe
本身實現了select的阻塞,因此沒必要再本身實現一套。
這兩個API有什麼區別:
// ListDirectory returns the contents of dir. func ListDirectory(dir string) ([]string, error)
// ListDirectory returns a channel over which // directory entries will be published. When the list // of entries is exhausted, the channel will be closed. func ListDirectory(dir string) chan string
首先,第一個API將全部的內容獲取出,放到一個slice中返回,這是一個同步調用的接口,直到列出全部的內容,才返回。有可能耗費內存,或者花費大量的時間。
第二個API更具有go風格,它是一個異步接口。啓動一個goroutine後,返回一個channel。後臺goroutine會將目錄內容寫到channel中。若是channel關閉,證實內容寫完了。
第二個channel版本的API有兩個問題:
有一個更更好的解放方法是使用回調函數:
func ListDirectory(dir string, fn func(string))
這就是filepath.WalkDir
的實現方法。
這裏給出監聽兩個不一樣端口的http服務的例子:8080是應用的端口,8001是請求性能分析/debug/pprof
的端口。
package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug http.ListenAndServe("0.0.0.0:8080", mux) // app traffic }
看起來不復雜的例子,可是隨着應用規模的增加,會暴露一些問題,如今咱們試着去解決:
func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() { http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { go serveDebug() serveApp() }
經過將serveApp
與serveDebug
的邏輯實如今本身的函數內,他們得以與main.main
解耦。咱們也遵循了上面的建議,將併發性交給調用者去作,好比go serveDebug()
。
可是上面的改進程序也存在必定的問題。若是serveApp
異常出錯返回,那麼main.main
也將返回,致使程序退出。並被其餘託管程序重啓(好比supervisor)
提示:就像將併發調用交給調用者同樣,程序自己的狀態監控和重啓,應該交給外部程序來作。
然而,serveDebug
處在一個獨立的goroutine,當它有錯誤返回時,並不影響其餘的goroutine運行。這時調用者發現/debug
處理程序沒法工做了,也會很困惑。
咱們須要確保任何一個相當重要的goroutine若是異常退出了,那麼整個程序也應該退出。
func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil { log.Fatal(err) } } func serveDebug() { if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil { log.Fatal(err) } } func main() { go serveDebug() go serveApp() select {} }
上面的程序中,serverApp
和serveDebug
都在http服務異常時,獲取error並寫log。在主函數中,使用select進行阻塞。這存在幾個問題:
ListenAndServer
返回nil,那麼log.Fatal
不會處理異常。這時可能端口已經關閉了,可是main沒法感知。log.Fatal
調用了os.Exit
,os.Exit
會無條件的結束程序,defers語句不會被執行,其餘的goroutine也沒法被通知到應該關閉。這個程序直接退出了,也不便於寫單元測試。提示:只應該在main.main
或者init函數中使用log.Fatal
咱們應該作什麼來保證各個goroutine安全退出,而且作好退出的清理工做呢?
func serveApp() error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() error { return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { done := make(chan error, 2) go func() { done <- serveDebug() }() go func() { done <- serveApp() }() for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } } }
咱們可使用一個channel來收集返回的error信息,channel的容量和goroutine相同,例子中是2,在main函數中,經過阻塞的等待channel讀取,來確保goroutine退出時,main函數能夠感知到。
因爲沒有安全的關閉channel,咱們不使用for range
`語句去便利channel,而是使用channel的容量做爲讀取的邊界條件。
如今咱們有了獲取goroutine錯誤信息的機制。咱們須要的還有從一個goroutine獲取信號,並轉發給其餘的goroutine的機制。
下面的例子中,咱們增長了一個輔助函數serve
,它實現了http.ListenAndServe
的啓動http服務的功能,而且增長了一個stop管道,以便接受結束消息。
func serve(addr string, handler http.Handler, stop <-chan struct{}) error { s := http.Server{ Addr: addr, Handler: handler, } go func() { <-stop // wait for stop signal s.Shutdown(context.Background()) }() return s.ListenAndServe() } func serveApp(stop <-chan struct{}) error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return serve("0.0.0.0:8080", mux, stop) } func serveDebug(stop <-chan struct{}) error { return serve("127.0.0.1:8001", http.DefaultServeMux, stop) } func main() { done := make(chan error, 2) stop := make(chan struct{}) go func() { done <- serveDebug(stop) }() go func() { done <- serveApp(stop) }() var stopped bool for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } if !stopped { stopped = true close(stop) } } }
上面的例子,咱們每次啓動goroutine會獲得一個done
channel,當從done
讀物到錯誤信息時,close stop channel,會使得其餘goroutine 正常退出。如此,就能夠實現main
函數正常的退出。
提示,本身寫這種處理退出的邏輯會顯得重複和微妙。開源代碼有實現相似的事情:
https://github.com/heptio/workgroup
,能夠參考