閱讀了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
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
,能夠參考