開始起飛-golang編碼技巧分享--Dave Cheney博客讀後整理

0. 引子

閱讀了Dave Cheney 關於go編碼的博客:Practical Go: Real world advice for writing maintainable Go programshtml

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

我對做者的文章進行整理翻譯,提取精煉,加上本身的理解,分享出來。但願也能給你們帶來幫助。程序員

但願你們支持原做者,原汁原味的內容能夠點擊 連接 閱讀。文中部分例子爲我的添加,若有不足敬請包容指出^ _ ^github

(PS:如涉及侵權,請與我聯繫,我會及時刪除文章,知識傳播無界,望你們支持)golang

1. 指導原則

我的認爲,編碼的最佳實踐本質是爲了提升代碼的迭代產能,減小bug的概率。(成本、效率、穩定)算法

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

  1. 簡潔
  2. 可讀性
  3. 開發效率

1.1 簡潔

簡潔是對於人而言的,若是代碼很複雜,甚至違法人的慣性理解,那麼修改和維護是牽一髮而動全身的。數據庫

1.2 可讀性

由於代碼被閱讀的次數遠遠多於被修改的次數。在做者看來,代碼被人的閱讀和修改的需求,比被機器執行的需求更強烈。go編碼最佳實踐第一步就應該肯定代碼的可讀性。編程

在我我的看來,相似於一致性算法中, raft爲何比paxos傳播和應用更廣,一個很重要的緣由就是raft更加易於理解,raft做者在論文中也提到,raft設計的最重要的初衷就是,paxos太難懂了。可讀性的重要性應該排在首位的。json

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,能夠參考
相關文章
相關標籤/搜索