開始起飛:Golang 編碼技巧分享

張大眼發表於2018-11-24

0. 引子

閱讀了Dave Cheney 關於go編碼的部落格:Practical Go: Real world advice for writing maintainable Go programs

實際應用下來,對我這個go入門者,提升效果顯著。

我對作者的文章進行整理翻譯,提取精煉,加上自己的理解,分享出來。希望也能給大家帶來幫助。

希望大家支援原作者,原汁原味的內容可以點選 連結 閱讀。文中部分例子為個人新增,如有不足敬請包容指出^ _ ^

(PS:如涉及侵權,請與我聯絡,我會及時刪除文章,知識傳播無界,望大家支援)

1. 指導原則

個人認為,編碼的最佳實踐本質是為了提高程式碼的迭代產能,減少bug的機率。(成本、效率、穩定)

作者Dave Cheney提到,go語言的最佳實踐的指導原則,需要考慮3點

  1. 簡潔
  2. 可讀性
  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 選擇辨識度高的名字,而不是選擇簡短的名字

就像編碼不是為了在儘量短的行數內,寫完程式。而是為了寫出可讀性高的程式。

同樣的,我們的命名標識也不是越短越好,而是容易被他人理解。

一個好名字應該具備的特點:

  1. 簡短:一個好名字應該在具備高辨識度的情況下,儘量簡短。
    1. 比如一個判斷使用者登入許可權的方法:壞名字是judgeAuth(容易歧義),judgeUserLoginAuthority(冗長)
    2. 好的例子judgeLoginAuth
  2. 描述性的:一個好的名字應該是描述變數和常量的用途,而非他們的內容;描述function的結果,或者method的行為,而不是他們的操作;描述package的目的,而非包含的內容。描述的準確性衡量了名字的好壞。
    1. 比如設計一個用來主從選舉的包。壞的package名字leader_operation,好的名字election
    2. 壞的function或者method名字ReturnElection,好的名字NewElection
    3. 壞的變數或者常量名字ElectionState,好的名字Role
  3. 可預測的:一個好的名字,僅通過名字,大家就可以推斷他們的用途。應該遵循大家的慣用理解。下面會詳細闡述。比如
    1. i,j,k常用來在迭代中描述引用計數值
    2. n通常用來表示計數累加值
    3. v通常表示一個編碼函式的值
    4. k通常用在map中的key
    5. s通常用來表示字串

2.2 命名的長度

關於名字的長度,我們有這些建議:

  1. 如果變數的宣告和它被最後一次使用的距離很短,可以使用短的變數名
  2. 如果一個變數很重要,那麼可以避免歧義,允許變數名稱長一些,消除歧義
  3. 變數的名字中請不要包含變數的型別名
  4. 常量的名字應該描述他們儲存的值,而不是如何使用該值
  5. 單個字母的名字可以用作迭代、邏輯分支判斷、引數和返回值。包和函式的名字請使用多個字母的組合。
  6. method、interface、package 請使用單個單詞
  7. 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.DBdbase *sql.DBDB *sql.DB,最好使用慣用的,一致的名字db *sql.DB。這樣你在其他的程式碼中,看到變數db時,也能推測到它是*sql.DB

還有一些慣用的短變數名字,這裡提一下:

  • i, j, k 用作迴圈中的索引
  • n 用在計數和累加
  • v 表示值
  • k 表示一個map或者slice 的key
  • s 表示字串

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點之一:

  1. 解釋做了什麼
  2. 解釋怎麼做
  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名字,那麼可能是以下原因:

  1. package的名字太通用了
  2. 這個package提供的服務與另一個package重合了。如果是這種情況,要考慮你的package設計了

4.2 package名字避免使用base,common,util

如果package內包含了一些列不相關的function,那麼很難說明這個package提供了哪些服務。這常常會導致package名字取一些通用的名字,類似utilities

大的專案中,經常會出現像utils或者helpers這樣的package名字。它們往往在依賴的最底層,以避免迴圈匯入問題。但是這樣也導致出現一些通用的包名稱,並且體現不出包的用意。

作者的建議是將utilshelpers這樣的package名字取取消掉:分析函式被呼叫的場景,如果可能的話,將函式轉移到呼叫者的package內,即使這涉及一些程式碼的拷貝。

提示:程式碼重複,比錯誤的抽象,代價更低

提示:使用單詞的複數命名通用的包。比如strings包含了string處理的通用函式。

我們應該儘可能的減少package的數量,比如現在有三個包commonclientserver,我們可以將其組合為一個包het/http,用client.go和server.go來區分client和server,避免引入過多的冗餘包。

提示,識別符號的名字包含了包名,比如net/httpGETfunction,呼叫的使用寫作http.Get,在識別符號起名和package起名時要考慮這一點

4.3 儘早Return

go語言沒有trycatch來做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。

有兩個保持所耦合的方法:

  1. 使用interface描述function或者method的行為
  2. 避免使用全域性狀態

在go程式中,變數宣告可以在function或者method作用域內,也可以在package作用域內。如果一個變數是public變數,並且首字母大寫,那麼所有包都可以訪問到這個變數。

可變的全域性變數會導致程式之間,各個獨立部分緊耦合。它對程式中的每個function都是不可見的引數。如果變數型別人為改變,或者被其他函式改變,那麼任何依賴這個變數的函式都會崩潰。

如果你想減少全域性變數帶來的耦合:

  1. 將相關的變數轉移到struct的引數中
  2. 使用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包含RequestResponse型別。client.go包含Client型別,server.go包含Server型別。
  • 如果你發現你的檔案中有相似的import宣告,嘗試合併他們,或者將他們的區別找出來,並且移動到新的包中。
  • 不同的檔案應該具備不同的職責,比如message.go應該負責HTTP序列化請求和響應。http.go應該包含底層的網路處理邏輯,client.goserver.go實現了HTTP業務邏輯,請求路由等。

提示:以名詞命名檔名

提示:go編譯器並行編譯不同的package,以及package不同的medhod和function。所以改變package內的函式位置不影響編譯時間。

5.1.2 內部的測試好於外部的測試

go工具支援使用testingpacakge在兩個地方寫測試用例。假設你的包叫做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/cimport,不能被其他層級專案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/httppackage的例子:

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. 錯誤處理

作者在他的部落格中已經寫過了錯誤處理:

inspection-errors

constant-error

此處只補充一些部落格中不涉及的內容。

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來註解錯誤資訊看起來很好,但是它也有一個缺點,它掩蓋了原始的錯誤資訊。作者認為將錯誤原本的返回對於鬆耦合的專案很重要。這有兩種情況,錯誤的原始型別才無關緊要:

  1. 判斷是否為nil
  2. 將錯誤資訊寫入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有兩個問題:

  1. 呼叫者無法區分出錯的場景和空內容的場景,在呼叫者看來,就是channel關閉了。
  2. 即使呼叫者提前獲取到了需要的內容,也無法提前結束從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()
}
複製程式碼

通過將serveAppserveDebug的邏輯實現在自己的函式內,他們得以與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 {}
}
複製程式碼

上面的程式中,serverAppserveDebug都在http服務異常時,獲取error並寫log。在主函式中,使用select進行阻塞。這存在幾個問題:

  1. 如果ListenAndServer返回nil,那麼log.Fatal不會處理異常。這時可能埠已經關閉了,但是main無法感知。
  2. log.Fatal呼叫了os.Exitos.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會得到一個donechannel,當從done讀物到錯誤資訊時,close stop channel,會使得其他goroutine 正常退出。如此,就可以實現main函式正常的退出。

提示,自己寫這種處理退出的邏輯會顯得重複和微妙。開原始碼有實現類似的事情:https://github.com/heptio/workgroup,可以參考

相關文章