0. 引子
閱讀了Dave Cheney 關於go編碼的部落格:Practical Go: Real world advice for writing maintainable Go programs
實際應用下來,對我這個go入門者,提升效果顯著。
我對作者的文章進行整理翻譯,提取精煉,加上自己的理解,分享出來。希望也能給大家帶來幫助。
希望大家支援原作者,原汁原味的內容可以點選 連結 閱讀。文中部分例子為個人新增,如有不足敬請包容指出^ _ ^
(PS:如涉及侵權,請與我聯絡,我會及時刪除文章,知識傳播無界,望大家支援)
1. 指導原則
個人認為,編碼的最佳實踐本質是為了提高程式碼的迭代產能,減少bug的機率。(成本、效率、穩定)
作者Dave Cheney提到,go語言的最佳實踐的指導原則,需要考慮3點:
- 簡潔
- 可讀性
- 開發效率
1.1 簡潔
簡潔是對於人而言的,如果程式碼很複雜,甚至違法人的慣性理解,那麼修改和維護是牽一髮而動全身的。
1.2 可讀性
因為程式碼被閱讀的次數遠遠多於被修改的次數。在作者看來,程式碼被人的閱讀和修改的需求,比被機器執行的需求更強烈。go編碼最佳實踐第一步就應該確定程式碼的可讀性。
在我個人看來,類似於一致性演算法中, raft為什麼比paxos傳播和應用更廣,一個很重要的原因就是raft更加易於理解,raft作者在論文中也提到,raft設計的最重要的初衷就是,paxos太難懂了。可讀性的重要性應該排在首位的。
1.3 開發效率
良好的編碼習慣,可以提高程式碼的交流效率。使得同事們看到程式碼就知道實現了什麼,而不必去逐行閱讀,大大節約了時間,提高開發效率。
此外,對於go語言本身而言,無論在編譯速度還是debug時間花費上,go相對C++也是開發效率大大提高的。
2. 命名
命名對編寫可讀性好的go程式至關重要!
曾經聽到這樣的一個言論:對變數的命名要像給自己孩子起名一樣慎重。
其實,不光是變數命名,還包括function、method、type、package等,命名都很重要。
2.1 選擇辨識度高的名字,而不是選擇簡短的名字
就像編碼不是為了在儘量短的行數內,寫完程式。而是為了寫出可讀性高的程式。
同樣的,我們的命名標識也不是越短越好,而是容易被他人理解。
一個好名字應該具備的特點:
- 簡短:一個好名字應該在具備高辨識度的情況下,儘量簡短。
- 比如一個判斷使用者登入許可權的方法:壞名字是
judgeAuth
(容易歧義),judgeUserLoginAuthority
(冗長) - 好的例子
judgeLoginAuth
- 比如一個判斷使用者登入許可權的方法:壞名字是
- 描述性的:一個好的名字應該是描述變數和常量的用途,而非他們的內容;描述function的結果,或者method的行為,而不是他們的操作;描述package的目的,而非包含的內容。描述的準確性衡量了名字的好壞。
- 比如設計一個用來主從選舉的包。壞的package名字
leader_operation
,好的名字election
- 壞的function或者method名字
ReturnElection
,好的名字NewElection
- 壞的變數或者常量名字
ElectionState
,好的名字Role
- 比如設計一個用來主從選舉的包。壞的package名字
- 可預測的:一個好的名字,僅通過名字,大家就可以推斷他們的用途。應該遵循大家的慣用理解。下面會詳細闡述。比如
i,j,k
常用來在迭代中描述引用計數值n
通常用來表示計數累加值v
通常表示一個編碼函式的值k
通常用在map中的keys
通常用來表示字串
2.2 命名的長度
關於名字的長度,我們有這些建議:
- 如果變數的宣告和它被最後一次使用的距離很短,可以使用短的變數名
- 如果一個變數很重要,那麼可以避免歧義,允許變數名稱長一些,消除歧義
- 變數的名字中請不要包含變數的型別名
- 常量的名字應該描述他們儲存的值,而不是如何使用該值
- 單個字母的名字可以用作迭代、邏輯分支判斷、引數和返回值。包和函式的名字請使用多個字母的組合。
- method、interface、package 請使用單個單詞
- pakcage名字也是呼叫方引用時需要註明的,所以請利用package的名字
舉一個作者文中的例子說明:
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者之間新增空行,可以增加可讀性。
2.2.1 上下文是關鍵
以上強調的原則需要在上下文中去實際判斷才行,萬事無絕對。
func (s *SNMP) Fetch(oid []int, index int) (int, error)
複製程式碼
與
func (s *SNMP) Fetch(o []int, i int) (int, error)
複製程式碼
相比,顯然使用oid命名更具備可讀性,而使用短變數o則不容易理解。
2.3 變數的命名不要攜帶變數的型別
因為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)
2.4 使用一致的命名
儘量不要將常見的變數名,換成其他的意思,這樣會造成讀者的歧義。
而且對於程式碼中一個型別的變數,不要多次改換它的名字,儘量使用一個名字。比如對於資料庫處理的變數,不要每次出現不同的名字,比如d *sql.DB
,dbase *sql.DB
,DB *sql.DB
,最好使用慣用的,一致的名字db *sql.DB
。這樣你在其他的程式碼中,看到變數db時,也能推測到它是*sql.DB
還有一些慣用的短變數名字,這裡提一下:
i, j, k
用作迴圈中的索引n
用在計數和累加v
表示值k
表示一個map或者slice 的keys
表示字串
2.5 使用一致的宣告型別
對於一個變數的宣告有多重宣告型別:
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來說,= 右側的型別,就是=左側的型別,上面三個例子中,最後一個使用:=
的例子,既能充分標識型別,又足夠簡潔。
22.6 作為團隊的一員
程式設計生涯大部分時間都是和作為團隊的一員,參與其中。作者建議大家最好保持團隊原來的編碼風格,即使那不是你偏愛的風格。要不人會導致整個工程風格不一致,這會更糟糕。
3. 註釋
註釋很重要,註釋應該做到以下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的作用並不好理解,增加註釋大大增加可理解性。
3.1 變數和常量的註釋應該描述他們的內容,而不是他們的作用
在上文中提到,變數和常量的名字又應該描述他們的目的。然而他們的註釋最好描述他們的內容。
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 複製程式碼
3.2 要對公共的名稱新增文件
因為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
}
複製程式碼
提示:在寫函式的內容前,最好先把函式的註釋寫出
3.2.1 不要在不完善的程式碼上寫註釋,而是重新它
如果遇到了不完善的程式碼,應該記錄一個issue,以便後續去修復。
傳統的方法是在程式碼上記錄一個todo,以便提醒。比如
// TODO(dfc) this is O(N^2), find a faster way to do this.
複製程式碼
3.2.2 如果要在一段程式碼上新增註釋,要想想能否重構它
好的程式碼本身就是註釋。如果要在一段程式碼上新增註釋,要問問自己,能否優化這段程式碼,而不用新增註釋。
函式應該只做一件事,如果你發現要在這個函式的註釋裡,提到其他函式,那麼該想想拆解這個冗餘的函式。
此外,函式越精簡,越便於測試。而且函式名本身就是最好的註釋。
4. package設計
每個go 的package 實際上都是自己的小型go程式。就好比一個function或者method的實現對呼叫者無關一樣,包內的對外暴露的function,method和型別的實現,和呼叫者無關。
一個好的go長鬚應該努力降低耦合度,這樣隨著專案的演化,一個package的變化不會影響到整個程式的其他package。
接下來會討論如何設計一個package,包括名字,型別,和編寫method和funciton的一些技巧。
4.1 一個好的packag首先有一個好名字
package 的名字應該儘量簡短,最好用一個單詞表示。考慮package名字的時候,不要想著我要在package內寫哪些型別,而是想著這個package要提供哪些服務。要以package提供哪些服務命名。
4.1.1 一個好的package名字應該是唯一的
一個專案那的package名字應該都是不同的。如果你發現可能要取相同的pcakge名字,那麼可能是以下原因:
- package的名字太通用了
- 這個package提供的服務與另一個package重合了。如果是這種情況,要考慮你的package設計了
4.2 package名字避免使用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起名時要考慮這一點
4.3 儘早Return
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。顯然後者更加易讀。
4.4 充分利用空值
如果一個變數宣告,但是不給定初始值,則會被自動賦值為空值。如果充分利用這些預設的空值,可以讓程式碼更加精簡。
- int 預設值是0
- 指標預設值是nil
- slice,map,channel預設值是nil
比如對於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, " "))
}
複製程式碼
4.5 避免package級別的狀態
書寫可維護的程式關鍵是保持鬆耦合--對一個package的更改,不應該影響到其他不直接依賴這個package的其他package。
有兩個保持所耦合的方法:
- 使用interface描述function或者method的行為
- 避免使用全域性狀態
在go程式中,變數宣告可以在function或者method作用域內,也可以在package作用域內。如果一個變數是public變數,並且首字母大寫,那麼所有包都可以訪問到這個變數。
可變的全域性變數會導致程式之間,各個獨立部分緊耦合。它對程式中的每個function都是不可見的引數。如果變數型別人為改變,或者被其他函式改變,那麼任何依賴這個變數的函式都會崩潰。
如果你想減少全域性變數帶來的耦合:
- 將相關的變數轉移到struct的引數中
- 使用interface減少型別和型別實現之間的耦合
5. 專案結構
5.1 使用盡可能少的,儘可能大的package
因為go語言中表述可見性的方法是用首字母區分,大寫表示可見,小寫表示不可見。如果一個識別符號是可見的,那麼它可以被任何任何其他的package使用。
鑑於此,怎麼才能避免過於複雜的package依賴結構?
提示:除了
cmd/
和internal
之外的每個package,都應該包含一些原始碼。
作者的建議是,使用盡可能少的package,儘可能大的package。大家的預設行為應該是不建立新的pcakge,如果建立過多的package,這會導致很多型別是public的。接下來會闡述更多的細節。
5.1.1 通過import語句管理檔案中的程式碼
如果你在這樣的規則設計package:以提供呼叫者什麼服務來安排。那麼是應該在一個package中的不同的file也如此設計呢?這裡給出一些建議:
- 每個package開始於一個與目錄同名的
.go
檔案。比如package http
應該在一個http目錄下的http.go
檔案中定義 - 隨著package內程式碼的增長,將不同的功能分佈在不同的檔案中。比如
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內的函式位置不影響編譯時間。
5.1.2 內部的測試好於外部的測試
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檔案,那麼以上的建議不適用。
5.1.3 使用internal
包減少對外暴露的公共API
如果你的專案中包含了多個package,並且有一些函式被其他package使用,但是並不想將這些函式作為對外專案的公共API,那麼可以使用internal/
。將程式碼放到此目錄下,可以使得首字母大寫的function只對本專案內公開呼叫,不對其他專案公開。
舉例來說,/a/b/c/internal/d/e/f
的目錄結構,c作為一個專案,internal
目錄下的包只能被/a/b/c
import,不能被其他層級專案import:如/a/b/g
5.2 保持主函式儘量精簡
main
函式以及main
包應該儘量精簡。因為在專案中只有一個main
包,同時程式只可能在main.main
或者main.init
被呼叫一次。這導致在main.mian
中很難編寫測試用例。應該將業務邏輯移動到其他的package中
提示:
main
應該解析引數,開啟資料庫連線,初始化logger等,將執行邏輯轉移到其他package。
6. API設計
6.1 設計不會被濫用的API
如果在簡單的場景,API被使用都很困難,那麼API的呼叫將會很複雜。如果API的呼叫很複雜,那麼它將會難以閱讀,並且容易被忽視。
6.1.1 警惕使用同型別的多引數函式
給定兩個或者更多相同型別的引數的函式,往往看起來很簡單,但是不容易使用。舉例:
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很難被正確的使用。
6.2 不應該強迫API的呼叫方提供他們不需要關注的參
如果你的API不必要求呼叫方提他們不關注的引數,那麼API將會更加的易於理解。
6.2.1 鼓勵將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)
複製程式碼
提示:慎重考慮輔助函式給程式設計師節省的時間到底有多少。清晰比簡潔更重要。
6.2.2 vars引數比[]T引數更好
將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
}
複製程式碼
6.3 讓函式定義他們需要的行為
如果需要將一個資料結構寫到磁碟中。可以像如下這麼寫:
// 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
的地方。這既帶來可擴充性,也減少了歧義:它只用來儲存,不進行資料流的關閉以及讀取操作。
7. 錯誤處理
作者在他的部落格中已經寫過了錯誤處理:
此處只補充一些部落格中不涉及的內容。
7.1 通過消除錯誤,將錯誤處理程式消除
比提示錯誤處理更好的是,不需要進行錯誤處理。(改進程式碼以便不必進行錯誤處理)
這一部分作者從John Ousterhout的近期的書籍《A philosophy of Software Design》中獲得啟發。這本書中有一張叫做“定義不復存在的錯誤”(Define Errors Out of Existence),這裡會應用到go語言中。
7.1.1 統計行數
讓我們寫一個同機檔案行數的程式碼
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()
中進行返回。
7.1.2 http返回值
來看一個處理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處理,但是這樣增加了讀者的閱讀負擔。並不能說是一種簡化)
7.2 一次只處理一個錯誤
一個錯誤的返回只應該被處理一次,如果想互聯錯誤則可以不去處理它:
// 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
複製程式碼
7.2.1 為錯誤增加上下文資訊
我們可以使用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,也可以保留錯誤的上下文資訊。
7.2.2 使用github.com/pkg/errors來包裝錯誤資訊
使用fmt.Errorf
來註解錯誤資訊看起來很好,但是它也有一個缺點,它掩蓋了原始的錯誤資訊。作者認為將錯誤原本的返回對於鬆耦合的專案很重要。這有兩種情況,錯誤的原始型別才無關緊要:
- 判斷是否為
nil
- 將錯誤資訊寫入log
但是有一些場景你需要保留原始的錯誤資訊。這種情況下你可以使用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原始型別的需求。
8. 併發
很多項選擇go語言是因為它的併發特性。go團隊竭盡全力讓併發實現更加低成本。但是使用go的併發也存在一些陷阱,下面介紹如何避開這些陷阱。
8.1 避免異常阻塞
這個程式看起來有什麼問題:
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的阻塞,所以不必再自己實現一套。
8.2 讓呼叫者去控制併發
這兩個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有兩個問題:
- 呼叫者無法區分出錯的場景和空內容的場景,在呼叫者看來,就是channel關閉了。
- 即使呼叫者提前獲取到了需要的內容,也無法提前結束從channel中讀取,直到channel關閉。這個方法對目錄內容多,佔用記憶體的場景更好,但是這並不比直接返回slice更快。
有一個更更好的解放方法是使用回撥函式:
func ListDirectory(dir string, fn func(string))
複製程式碼
這就是filepath.WalkDir
的實現方法。
8.3 當goroutine將要停止時,不要啟動它
這裡給出監聽兩個不同埠的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
,可以參考