【譯】Go 語言實踐:編寫可維護的程序的建議

本文爲 QCon 2018 上海站主題演講嘉賓、Heptio 資深工程師、著名 Go 語言專家 David Cheney 關於 Go 語言實踐的英文分享。 本文主要基於譯文cloud.tencent.com/developer/a… 整理髮布html

引言

接下來這兩場我將給你們一些編寫 Go 代碼的最佳實踐。git

今天這是一個研討會風格的演講,我會摒棄那些絢麗的 PPT,而是使用您們能夠直接帶走的文檔程序員

您能夠在這裏找到這個演講最新的在線版本: dave.cheney.net/practical-g…github

1.指導原則

咱們要談論在一個編程語言中的最佳實踐,那麼咱們首先應該明確什麼是「最佳」。若是您們聽了我昨天那場講演的話,您必定看到了來自 Go 團隊的 Russ Cox 講的一句話:golang

軟件工程,是您在編程過程當中增長了工期或者開發人員以後發生的那些事。 — Russ Coxsql

Russ 是在闡述軟件「編程」和軟件「工程」之間的區別,前者是您寫的程序,然後者是一個讓更多的人長期使用的產品。軟件工程師會來來去去地更換,團隊也會成長或者萎縮,需求也會發生變化,新的特性也會增長,bug 也會被修復,這就是軟件「工程」的本質。數據庫

我多是現場最先的 Go 語言用戶,但與其說個人主張來自個人資歷,不如說我今天講的是真實來自於 Go 語言自己的指導原則,那就是:編程

  1. 簡單性
  2. 可讀性
  3. 生產率

您可能已經注意到,我並無提性能或者併發性。實際上有很多的語言執行效率比 Go 還要高,但它們必定沒有 Go 這麼簡單。有些語言也以併發性爲最高目標,但它們的可讀性和生產率都很差。 性能和併發性都很重要,但它們不如簡單性、可讀性和生產率那麼重要。json

1.1 簡單性

爲何咱們要力求簡單,爲何簡單對 Go 語言編程如此重要?api

咱們有太多的時候感嘆「這段代碼我看不懂」,是吧?咱們懼怕修改一丁點代碼,生怕這一點修改就致使其餘您不懂的部分出問題,而您又沒辦法修復它。

這就是複雜性。複雜性把可讀的程序變得不可讀,複雜性終結了不少軟件項目。

簡單性是 Go 的最高目標。不管咱們寫什麼程序,咱們都應該能一致認爲它應當簡單。

1.2 可讀性

Readability is essential for maintainability. — Mark Reinhold, JVM language summit 2018 可讀性對於可維護性相當重要。

爲何 Go 代碼的可讀性如此重要?爲何咱們應該力求可讀性?

Programs must be written for people to read, and only incidentally for machines to execute. — Hal Abelson and Gerald Sussman, Structure and Interpretation of Computer Programs 程序應該是寫來被人閱讀的,而只是順帶能夠被機器執行。

可閱讀性對全部的程序——不只僅是 Go 程序,都是如此之重要,是由於程序是人寫的而且給其餘人閱讀的,事實上被機器所執行只是其次。

代碼被閱讀的次數,遠遠大於被編寫的次數。一段小的代碼,在它的整個生命週期,可能被閱讀成百上千次。

The most important skill for a programmer is the ability to effectively communicate ideas. — Gastón Jorquera ^1 程序員最重要的技能是有效溝通想法的能力。

可讀性是弄清楚一個程序是在作什麼事的關鍵。若是您都不知道這個程序在作什麼,您如何去維護這個程序?若是一個軟件不可用被維護,那就可能被重寫,而且這也多是您公司最後一次在 GO 上面投入了。

若是您僅僅是爲本身我的寫一個程序,可能這個程序是一次性的,或者使用這個程序的人也只有您一個,那您想怎樣寫就怎樣寫。但若是是多人合做貢獻的程序,或者由於它解決人們的需求、知足某些特性、運行它的環境會變化,而在一個很長的時間內被不少人使用,那麼程序的可維護性則必須成爲目標。

編寫可維護的程序的第一步,那就是確保代碼是可讀的。

1.3 生產率

Design is the art of arranging code to work today, and be changeable forever. — Sandi Metz > 設計是一門藝術,要求編寫的代碼當前可用,而且之後仍能被改動。

我想重點闡述的最後一個基本原則是生產率。開發者的生產率是一個複雜的話題,但歸結起來就是:爲了有效的工做,您由於一些工具、外部代碼庫而浪費了多少時間。Go 程序員應該感覺獲得,他們在工做中能夠從不少東西中受益了。(Austin Luo:言下之意是,Go 的工具集和基礎庫完備,不少東西觸手可得。)

有一個笑話是說,Go 是在 C++ 程序編譯過程當中被設計出來的。快速的編譯是 Go 語言用以吸引新開發者的關鍵特性。編譯速度仍然是一個不變的戰場,很公平地說,其餘語言須要幾分鐘才能編譯,而 Go 只須要幾秒便可完成。這有助於 Go 開發者擁有動態語言開發者同樣的高效,但卻不會面臨那些動態語言自己可靠性的問題。

Go 開發者意識到代碼是寫來被閱讀的,而且把閱讀放在編寫之上。Go 致力於從工具集、習慣等方面強制要求代碼必須編寫爲一種特定樣式,這消除了學習項目特定術語的障礙,同時也能夠僅僅從「看起來」不正確便可幫助開發者發現潛在的錯誤。

Go 開發者不會整日去調試那些莫名其妙的編譯錯誤。他們也不會整日浪費時間在複雜的構建腳本或將代碼部署到生產中這事上。更重要的是他們不會花時間在嘗試搞懂同事們寫的代碼是什麼意思這事上。

當 Go 語言團隊在談論一個語言必須擴展時,他們談論的就是生產率。

2標識符

咱們要討論的第一個議題是標識符。標識符是一個名稱的描述詞,這個名稱能夠是一個變量的名稱、一個函數的名稱、一個方法的名稱、一個類型的名稱或者一個包的名稱等等。

鑑於 Go 的語法限制,咱們爲程序中的事物選擇的名稱對咱們程序的可讀性產生了過大的影響。良好的可讀性是評判代碼質量的關鍵,所以選擇好名稱對於 Go 代碼的可讀性相當重要。

2.1選擇清晰的名稱,而不是簡潔的名稱

Go 不是專一於將代碼精巧優化爲一行的那種語言,Go 也不是致力於將代碼精煉到最小行數的語言。咱們並不追求源碼在磁盤上佔用的空間更少,也不關心錄入代碼須要多長時間。

這個清晰度的關鍵就是咱們爲 Go 程序選擇的標識符。讓咱們來看看一個好的名稱應當具有什麼吧:

  • 好的名稱是簡潔的。一個好的名稱未必是儘量短的,但它確定不會浪費任何無關的東西在上面,好名字具備高信噪比。
  • 好的名稱是描述性的。一個好的名稱應該描述一個變量或常量的使用,而非其內容。一個好的命名應該描述函數的結果或一個方法的行爲,而不是這個函數或方法自己的操做。一個好的名稱應該描述一個包的目的,而不是包的內容。名稱描述的東西越準確,名稱越好。
  • 好的名稱是可預測的。您應該可以從名稱中推斷出它的使用方式,這是選擇描述性名稱帶來的做用,同時也遵循了傳統。Go 開發者在談論慣用語時,便是說的這個。

接下來讓咱們深刻地討論一下。

2.2 標識符長度

有時候人們批評 Go 風格推薦短變量名。正如 Rob Pike 所說,「Go 開發者想要的是合適長度的標識符」。^1 Andrew Gerrand 建議經過使用更長的標識符向讀者暗示它們具備更高的重要性。

據此,咱們能夠概括一些指導意見:

  • 短變量名稱在聲明和上次使用之間的距離很短時效果很好。
  • 長變量名須要證實其不一樣的合理性:越長的變量名,越須要更多的理由來證實其合理。冗長、繁瑣的名稱與他們在頁面上的權重相比,攜帶的信息很低。
  • 不要在變量名中包含其類型的名稱。
  • 常量須要描述其存儲的值的含義,而不是怎麼使用它。
  • 單字母變量可用於循環或邏輯分支,單詞變量可用於參數或返回值,多詞短語可用於函數和包這一級的聲明。
  • 單詞可用於方法、接口和包
  • 請記住,包的命名將成爲用戶引用它時採用的名稱,確保這個名稱更有意義。 讓咱們來看一個示例:
type Person struct {
Name string
Age  int
}
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
}
複製代碼

在這個示例中,範圍變量p在定義以後只在接下來的一行使用。p在整頁源碼和函數執行過程當中都只生存一小段時間。對p感興趣的讀者只須要查看兩行代碼便可。

與之造成對比的是,變量people在函數參數中定義,而且存在了 7 行,同理的還有sum和count,這他們使用了更長的名稱,讀者必須關注更普遍的代碼行。

我也能夠使用s而不是sum,用c(或n)而不是count,但這會將整個程序中的變量都彙集在相同的重要性上。我也能夠使用p而不是people,可是這樣又有一個問題,那就是for ... range循環中的變量又用什麼?單數的 person 看起來也很奇怪,生存時間極短命名卻比導出它的那個值更長。

Austin Luo:這裏說的是,若數組people用變量名p,那麼從數組中獲取的每個元素取名就成了問題,好比用person,即便使用person看起來也很奇怪,一方面是單數,一方面person的生存週期只有兩行(很短),命名比生存週期更長的p(people)還長了。 小竅門:跟使用空行在文檔中分段同樣,使用空行將函數執行過程分段。在函數AverageAge中有按順序的三個操做。第一個是先決條件,檢查當people爲空時咱們不會除零,第二個是累加總和和計數,最後一個是計算平均數。

2.2.1 上下文是關鍵

絕大多數的命名建議都是根據上下文的,意識到這一點很重要。我喜歡稱之爲原則,而不是規則。 i和index 這兩個標識符有什麼不一樣?咱們很難確切地說其中一個比另外一個好,好比:

for index := 0; index < len(s); index++ {
}
複製代碼

上述代碼的可讀性,基本上都會認爲比下面這段要強:

for i := 0; i < len(s); i++ {
}
複製代碼

但我表示不贊同。由於不管是i仍是index,都是限定於for循環體的,更冗長的命名,並無讓咱們更容易地理解這段代碼。

話說回來,下面兩段代碼那一段可讀性更強呢?

func (s *SNMP) Fetch(oid []int, index int) (int, error)
複製代碼

或者

func (s *SNMP) Fetch(o []int, i int) (int, error)
複製代碼

在這個示例中,oid是SNMP對象 ID 的縮寫,所以將其略寫爲 o 意味着開發者必須將他們在文檔中看到的常規符號轉換理解爲代碼中更短的符號。一樣地,將index簡略爲i,減小了其做爲SNMP消息的索引的含義。

小竅門:在參數聲明中不要混用長、短不一樣的命名風格。

2.3 命名中不要包含所屬類型的名稱

正如您給寵物取名同樣,您會給狗取名「汪汪」,給貓取名爲「咪咪」,但不會取名爲「汪汪狗」、「咪咪貓」。出於一樣的緣由,您也不該在變量名稱中包含其類型的名稱。

變量命名應該體現它的內容,而不是類型。咱們來看下面這個例子:

var usersMap map[string]*User
複製代碼

這樣的命名有什麼好處呢?咱們能知道它是個 map,而且它與*User類型有關,這可能還不錯。可是 Go 做爲一種靜態類型語言,它並不會容許咱們在須要標量變量的地方意外地使用到這個變量,所以Map後綴其實是多餘的。 如今咱們來看像下面這樣定義變量又是什麼狀況:

var (
companiesMap map[string]*Company
productsMap map[string]*Products
)
複製代碼

如今這個範圍內咱們有了三個 map 類型的變量了:usersMap,companiesMap,以及 productsMap,全部這些都從字符串映射到了不一樣的類型。咱們知道它們都是 map,咱們也知道它們的 map 聲明會阻止咱們使用一個代替另外一個——若是咱們嘗試在須要map[string]*User的地方使用companiesMap,編譯器將拋出錯誤。在這種狀況下,很明顯Map後綴不會提升代碼的清晰度,它只是編程時須要鍵入的冗餘內容。(Austin Luo:陳舊的思惟方式)

個人建議是,避免給變量加上與類型相關的任何後綴。

小竅門:若是users不能描述得足夠清楚,那usersMap也必定不能。

這個建議也適用於函數參數,好比:

type Config struct {
}
func WriteConfig(w io.Writer, config *Config)
複製代碼

Config參數命名爲config是多餘的,咱們知道它是個Config,函數簽名上寫得很清楚。

在這種狀況建議考慮conf或者c——若是生命週期足夠短的話。

若是在一個範圍內有超過一個*Config,那命名爲conf一、conf2的描述性就比original、updated更差,並且後者比前者更不容易出錯。

NOTE:不要讓包名佔用了更適合變量的名稱。 導入的標識符是會包含它所屬包的名稱的。 例如咱們很清楚context.Context是包context中的類型Context。這就致使咱們在咱們本身的包裏,再也沒法使用context做爲變量或類型名了。 func WriteLog(context context.Context, message string) 這沒法編譯。這也是爲何咱們一般將context.Context類型的變量命名爲ctx的緣由,如: func WriteLog(ctx context.Context, message string)

2.4 使用一致的命名風格

一個好名字的另外一個特色是它應該是可預測的。閱讀者應該能夠在第一次看到的時候就可以理解它如何使用。若是遇到一個約定俗稱的名字,他們應該可以認爲和上次看到這個名字同樣,一直以來它都沒有改變意義。 例如,若是您要傳遞一個數據庫句柄,請確保每次的參數命名都是同樣的。與其使用 d *sql.DBdbase *sql.DBDB *sql.DBdatabase *sql.DB,還不如都統一爲:

db *sql.DB
複製代碼

這樣作能夠增進熟悉度:若是您看到db,那麼您就知道那是個*sql.DB,而且已經在本地定義或者由調用者提供了。 對於方法接收者也相似,在類型的每一個方法中使用相同的接收者名稱,這樣可讓閱讀者在跨方法閱讀和理解時更容易主觀推斷。

Austin Luo:「接收者」是一種特殊類型的參數。^2 比如func (b *Buffer) Read(p []byte) (n int, err error),它一般只用一到兩個字母來表示,但在不一樣的方法中仍然應當保持一致。 注意:Go 中對接收者的短命名規則慣例與目前提供的建議不一致。這只是早期作出的選擇之一,而且已經成爲首選的風格,就像使用CamelCase而不是snake_case同樣。

小竅門:Go 的命名風格規定接收器具備單個字母名稱或其派生類型的首字母縮略詞。有時您可能會發現接收器的名稱有時會與方法中參數的名稱衝突,在這種狀況下,請考慮使參數名稱稍長,而且仍然不要忘記一致地使用這個新名稱。

最後,某些單字母變量傳統上與循環和計數有關。例如,i,j,和k一般是簡單的for循環變量。n一般與計數器或累加器有關。 v一般是某個值的簡寫,k一般用於映射的鍵,s一般用做string類型參數的簡寫。

與上面db的例子同樣,程序員指望i是循環變量。若是您保證i始終是一個循環變量——而不是在for循環以外的狀況下使用,那麼當讀者遇到一個名爲i或者j的變量時,他們就知道當前還在循環中。

小竅門:若是您發如今嵌套循環中您都使用完i,j,k了,那麼很顯然這已經到了將函數拆得更小的時候了。 使用一致的聲明風格

2.5 使用一致的聲明風格

Go 中至少有 6 種聲明變量的方法(Austin Luo:做者說了 6 種,但只列了 5 種)

  • var x int = 1
  • var x = 1
  • var x int; x = 1
  • var x = int(1)
  • x := 1

我敢確定還有更多我沒想到的。這是 Go 的設計師認識到多是一個錯誤的地方,但如今改變它爲時已晚。有這麼多不一樣的方式來聲明變量,那麼咱們如何避免每一個 Go 程序員選擇本身個性獨特的聲明風格呢?

我想展現一些在我本身的程序裏聲明變量的建議。這是我儘量使用的風格。

  • 只聲明,不初始化時,使用var。在聲明以後,將會顯式地初始化時,使用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而不是短聲明語法(Austin Luo::=)的要求一致——儘管我稍後會說您根本不該該使用包級變量。

  • 既聲明,也初始化時,使用:=。當同時要聲明和初始化變量時,換言之咱們不讓變量隱式地被初始化爲零值時,我建議使用短聲明語法的形式。這使得讀者清楚地知道:=左側的變量是有意被初始化的。 爲解釋緣由,咱們回頭再看看上面的例子,但這一次每一個變量都被有意初始化了:
var players int = 0
var things []Thing = nil
var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)
複製代碼

第一個和第三個示例中,由於 Go 沒有從一種類型到另外一種類型的自動轉換,賦值運算符左側和右側的類型一定是一致的。編譯器能夠從右側的類型推斷出左側所聲明變量的類型。對於這個示例能夠更簡潔地寫成這樣:

var players = 0
var things []Thing = nil
var thing = new(Thing)
json.Unmarshall(reader, thing)
複製代碼

因爲0是players的零值,所以爲players顯式地初始化爲0就顯得多餘了。因此爲了更清晰地代表咱們使用了零值,應該寫成這樣:

var players int
複製代碼

那第二條語句呢?咱們不能忽視類型寫成:

var things = nil
複製代碼

由於nil根本就沒有類型^2。相反,咱們有一個選擇,咱們是否但願切片的零值?

var things []Thing
複製代碼

或者咱們是否但願建立一個沒有元素的切片?

var things = make([]Thing, 0)
複製代碼

若是咱們想要的是後者,這不是個切片類型的零值,那麼咱們應該使用短聲明語法讓閱讀者很清楚地明白咱們的選擇:

things := make([]Thing, 0)
複製代碼

這告訴了讀者咱們顯式地初始化了things。 再來看看第三個聲明:

var thing = new(Thing)
複製代碼

這既顯式地初始化了變量,也引入了 Go 程序員不喜歡並且很不經常使用的new關鍵字。若是咱們遵循短命名語法的建議,那麼這句將變成:

thing := new(Thing)
複製代碼

這很清楚地代表,thing被顯式地初始化爲new(Thing)的結果——一個指向Thing的指針——但仍然保留了咱們不經常使用的new。咱們能夠經過使用緊湊結構初始化的形式來解決這個問題,

thing := &Thing{}
複製代碼

這和new(Thing)作了一樣的事——也所以不少 Go 程序員對這種重複感受不安。不過,這一句仍然意味着咱們爲thing明確地初始化了一個Thing{}的指針——一個Thing的零值。

在這裏,咱們應該意識到,thing被初始化爲了零值,而且將它的指針地址傳遞給了json.Unmarshall:

var thing Thing
json.Unmarshall(reader, &thing)
複製代碼

注意:固然,對於任何經驗法則都有例外。好比,有些變量之間很相關,那麼與其寫成這樣: var min int max := 1000不如寫成這樣更具可讀性: min, max := 0, 1000

綜上所述:

  • 只聲明,不初始化時,使用var。
  • 既聲明,也顯式地初始化時,使用:=。

小竅門:使得機巧的聲明更加顯而易見。 當某件事自己很複雜時,應當使它看起來就複雜。 var length uint32 = 0x80 這裏的length可能和一個須要有特定數字類型的庫一塊兒使用,而且length被很明確地指定爲uint32類型而不僅是短聲明形式: length := uint32(0x80) 在第一個例子中,我故意違反了使用var聲明形式和顯式初始化程序的規則。這個和我慣常形式不一樣的決定,可讓讀者意識到這裏須要注意。

2.6 成爲團隊合做者

我談到了軟件工程的目標,即生成可讀,可維護的代碼。而您的大部分職業生涯參與的項目可能您都不是惟一的做者。在這種狀況下個人建議是遵照團隊的風格。

在文件中間改變編碼風格是不適合的。一樣,即便您不喜歡,可維護性也比您的我的喜愛有價值得多。個人原則是:若是知足gofmt,那麼一般就不值得再進行代碼風格審查了。

小竅門:若是您要橫跨整個代碼庫進行重命名,那麼不要在其中混入其餘的修改。若是其餘人正在使用 git bisect,他們必定不肯意從幾千行代碼的重命名中「跋山涉水」地去尋找您別的修改。

3 代碼註釋

在咱們進行下一個更大的主題以前,我想先花幾分鐘說說註釋的事。

Good code has lots of comments, bad code requires lots of comments. — Dave Thomas and Andrew Hunt, The Pragmatic Programmer 好的代碼中附帶有大量的註釋,壞的代碼缺乏大量的註釋。

代碼註釋對 Go 程序的可讀性極爲重要。一個註釋應該作到以下三個方面的至少一個:

  1. 註釋應該解釋「作什麼」。
  2. 註釋應該解釋「怎麼作的」。
  3. 註釋應該解釋「爲何這麼作」。

第一種形式適合公開的符號:

// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.
複製代碼

第二種形式適合方法內的註釋:

// 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,
  },
}
複製代碼

在這個示例中,很難當即弄清楚把HealthyPanicThreshold的百分比設置爲零會產生什麼影響。註釋就用來明確將值設置爲0其實是禁用了panic閾值的這種行爲。

3.1 變量和常量上的註釋應當描述它的內容,而非目的

我以前談過,變量或常量的名稱應描述其目的。向變量或常量添加註釋時,應該描述變量的內容,而不是定義它的目的。

const randomNumber = 6 // determined from an unbiased die
複製代碼

這個示例的註釋描述了「爲何」randomNumber被賦值爲 6,也說明了 6 這個值是從何而來的。但它沒有描述randomNumber會被用到什麼地方。下面是更多的例子:

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
複製代碼

如在 RFC 7231 的第 6.2.1 節中定義的那樣,在 HTTP 語境中 100 被當作StatusContinue

小竅門:對於那些沒有初始值的變量,註釋應當描述誰將負責初始化它們 // sizeCalculationDisabled indicates whether it is safe // to calculate Types' widths and alignments. See dowidth. var sizeCalculationDisabled bool 這裏,經過註釋讓讀者清楚函數dowidth在負責維護sizeCalculationDisabled的狀態。 小竅門:隱藏一目瞭然的東西 Kate Gregory 提到一點^3,有時一個好的命名,能夠省略沒必要要的註釋。 // registry of SQL drivers var registry = make(mapstringsql.Driver) 註釋是源碼做者加的,由於registry沒能解釋清楚定義它的目的——它是個註冊表,可是什麼的註冊表? 經過重命名變量名爲sqlDrivers,如今咱們很清楚這個變量的目的是存儲 SQL 驅動。 var sqlDrivers = make(mapstringsql.Driver) 如今註釋已經多餘了,能夠移除。

3.2 老是爲公開符號寫文檔說明

由於 godoc 將做爲您的包的文檔,您應該老是爲每一個公開的符號寫好註釋說明——包括變量、常量、函數和方法——全部定義在您包內的公開符號。 這裏是 Go 風格指南的兩條規則:

  • 任何既不明顯也不簡短的公共功能必須加以註釋。
  • 不管長度或複雜程度如何,都必須對庫中的任何函數進行註釋。
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
}
複製代碼

請注意,LimitedReader的聲明緊接在使用它的函數以後,而且LimitedReader.Read又緊接着定義在LimitedReader以後,即使LimitedReader.Read自己沒有文檔註釋,那和很清楚它是io.Reader的一種實現。

小竅門:在您編寫函數以前先寫描述這個函數的註釋,若是您發現註釋很難寫,那就代表您正準備寫的這段代碼必定難以理解。

3.2.1 不要爲壞的代碼寫註釋,重寫它

Don’t comment bad code — rewrite it — Brian Kernighan 不要爲壞的代碼寫註釋——重寫它

爲粗製濫造的代碼片斷着重寫註釋是不夠的,若是您遭遇到一段這樣的註釋,您應該發起一個問題(issue)從而記得後續重構它。技術債務只要不是過多就沒有關係。 在標準庫的慣例是,批註一個 TODO 風格的註釋,說明是誰發現了壞代碼。 註釋中的姓名並不意味着承諾去修復問題,但在解決問題時,他多是最合適的人選。其餘批註內容通常還有日期或者問題編號。

3.2.2與其爲一大段代碼寫註釋,不如重構它

Good code is its own best documentation. As you’re about to add a comment, ask yourself, 'How can I > improve the code so that this comment isn’t needed?' Improve the code and then document it to make it even clearer. — Steve McConnell 好的代碼即爲最好的文檔。在您準備添加一行註釋時,問本身,「我要如何改進這段代碼從而使它不須要註釋?」優化代碼,而後註釋它使之更清晰。

函數應該只作一件事。若是您發現一段代碼由於與函數的其餘部分不相關於是須要註釋時,考慮將這段代碼拆分爲獨立的函數。

除了更容易理解以外,較小的函數更容易單獨測試,如今您將不相關的代碼隔離拆分到不一樣的函數中,估計只有函數名纔是惟一須要的文檔註釋了

4 包的設計

Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules' implementations. — Dave Thomas 編寫內斂的代碼——模塊不向外部透露任何沒必要要的信息,也不依賴外部模塊的實現。

每一個 Go Package 事實上自身都是一個小的 Go 程序。正如函數或方法的實現對其調用者不重要同樣,構成公開 API 的函數、方法、類型的實現——其行爲——對調用者也不重要。

一個好的 Go Package 應該致力於較低的源碼級耦合,這樣,隨着項目的增加,對一個包的更改不會級聯影響其餘代碼庫。那些「世界末日」似的重構讓代碼的更新優化變得極其困難,也讓工做在這樣的代碼庫上的開發者的生產效率極度地受限。

在這一節中我會來談一談包的設計,包括包的命名、類型的命名,以及編寫方法和函數的一些小技巧。 一個好的包從它的名稱開始

4.1 一個好的包從它的名稱開始

編寫一個好的 Go 程序包從命名開始。好好思考您的軟件包的名字,僅用一個詞來描述它是什麼。(Austin Luo:就如同「電梯遊說」同樣,您只能在極短的時間極少的話語的狀況下描述您要表達的東西。) 正如我在上一節講變量命名同樣,包的名稱也一樣很是重要。以個人經驗來看,咱們應當思考的不是「我在這個包裏應當放哪些類型」,而是「包提供的服務都應該作什麼」。一般這個問題的答案不該該是「這個包提供了某某類型」,而是「這個包讓您能夠進行 HTTP 通訊」。

小竅門:以包「提供」的東西來命名,而不是以「包含」的東西來命名。

4.1.1好的包名應該是惟一的

在您的項目裏,每一個包名都應該是惟一的。這個建議很容易理解,也很容易遵照。包的命名應該源於它的目的——若是您發現有兩個包須要取相同的名字,那多是下面兩種狀況:

  • 包的名稱太通用了。
  • 和另一個相似名稱的包重複了。在這種狀況下,您應該從新評審設計或者直接將這兩個包合併。

4.2 避免將包命名爲base、common、util

一個低劣的名稱一般是「utility」。這些一般是隨着時間推移沉澱下來的通用幫助類或者工具代碼。這種包裏一般混合有各類不相關的功能,而且由於其通用性,以致於難以準確地描述這個包都提供了些什麼。這一般致使包名來源於這個包「包含」的東西——一堆工具。

像utils或helpers這樣的名稱,一般在一些大型項目中找到,這些項目中已經開發了較深的層次結構,而且但願在共享這些幫助類函數時,避免循環導入。雖然打散這些工具函數到新的包也能打破循環導入,可是由於其自己是源於項目的設計問題,包名稱並未反映其目的,所以打散它也僅僅只起到了打破導入循環的做用而已。

針對優化utils或helpers這種包名,個人建議是分析它們是在哪裏被使用,而且是否有可能把相關函數挪到調用者所在的包。即使這可能致使一些重複的幫助類代碼,但這也比在兩個包之間引入一個導入依賴來的更好。

A little duplication is far cheaper than the wrong abstraction. — Sandy Metz (一點點的)重複遠比錯誤的抽象更值得。

在多個地方使用工具類方法的狀況下,優先選擇多個包(的設計),每一個包專一於一個單獨的方面,而不是整個包。(Austin Luo:Separation Of Concerns。)

小竅門:使用複數形式命名工具包。好比strings是字符串的處理工具。

像base或common這樣的名稱,經常使用於一個通用的功能被分爲兩個或多個實現的狀況,或者一些用於客戶端、服務端程序,而且被重構爲單獨通用類型的包。我認爲解決這個問題的方法是減小包的數量,把客戶端、服務端的通用代碼合併到一個統一包裏。

具體例子,net/http包總並無client和server這兩個子包,取而代之的是隻有兩個名爲client.go和server.go的文件,每一個文件處理各自的類型,以及一個transport.go文件用於公共消息傳輸的代碼。

小竅門:標識符的名稱包括其包的名稱 牢記標識符的名稱包含其所在包的名稱,這一點很重要 net/http包中的Get函數,在其餘包引用時變成了http.Get。 strings包中的Reader類型,在其餘包導入後變成了strings.Reader。 net包中的Error接口很明確地與網絡錯誤相關。

4.3 快速返回,而不是深層嵌套

正如 Go 並不使用異常來控制執行流程,也不須要深度縮進代碼只爲了在頂層結構添加一個try...catch...塊。與把成功執行的路徑向右側一層一層深度嵌套相比,Go 風格的代碼是隨着函數的執行,成功路徑往屏幕下方移動。個人朋友 Mat Ryer 稱這種方式爲「視線」編碼。^4

這是經過「保護條款」來實現的(Austin Luo: 相似咱們常說的防護式編程):條件代碼塊在進入函數時當即斷言前置條件。這裏是bytes包裏的一個示例:

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
}
複製代碼

一旦進入UnreadRune,就會檢查b.lastRead,若是以前的操做不是ReadRune就會當即返回錯誤。從這裏開始,函數執行下去的其他部分,咱們就能明確確定b.lastRead比opInvalid大了。

與沒有使用「保護條款」的相同功能代碼對比看看:

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")
}
複製代碼

最一般的、成功的狀況,被縮進到了第一個if條件中了。而且成功的退出條件 return nil,須要很是當心地與閉口括號(})對應。接下來,最後一行代碼返回了一個錯誤,而且咱們須要回退跟蹤到函數的開口括號({)才知道執行控制流何時到達了這裏。 對於讀者和維護程序員來講,這更容易出錯,所以 Go 更喜歡使用「保護條款」並儘早返回錯誤。

4.4 讓零值變得有意義

假設沒有明確提供顯示初始化器,每一個變量聲明以後都會被自動初始化爲零內存對應的值,這就是零值。零值與其類型有關:數值類型爲0,指針爲nil,切片、映射、管道等也一樣(爲nil)。

始終將值設置爲已知默認值,對於程序的安全性和正確性很是重要,而且能夠使 Go 程序更簡單,更緊湊。這就是 Go 程序員在說「給您的結構一個有用的零值」時所表達的意思。

咱們來看sync.Mutex這類型。它有兩個未導出的整數型字段,表示互斥鎖的內部狀態。因爲零值,不管什麼時候一個sync.Mutex類型變量被聲明後,這些字段都將被設置爲0。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()
}
複製代碼

Austin Luo:原文爲「useful」,我在此譯爲「有意義」而不是「有用」,意在強調其零值是符合業務的、符合邏輯的,而且也是初始的、默認的,而不是「不用管它,讓它爲零好了」。 這與變量的命名也息息相關,好比: isCacheEnabled bool // 緩存是否被啓用 isCacheDisabled bool // 緩存是否被禁用 對於上述兩個變量,看起來都差很少,隨意定義其中一個便可,惟一的差異只是一個表示啓用一個表示禁用而已。可是結合考慮「業務要求默認啓用緩存」和「bool 的零值爲 false」,那麼顯然咱們應該定義isCacheDisabled bool而不是前者。一方面,調用者不顯式賦值時默認零值爲false,另外一方面值爲false時表達的含義與業務要求默認啓用緩存一致。 這才使得零值真正地有意義,正如示例中註釋的那行i.mu同樣,不顯示初始化其表明的是默認鎖是可用的。

另外一個有意義零值的類型示例是bytes.Buffer。您能夠無需顯式初始化地聲明bytes.Buffer而後當即開始向它寫入數據。

func main() {
  var b bytes.Buffer
  b.WriteString("Hello, world!\n")
  io.Copy(os.Stdout, &b)
}
複製代碼

切片的一個有用性質是它的零值爲nil,咱們只須要去看看切片的運行時定義便可理解它的合理性:

type slice struct {
  array *[...]T // pointer to the underlying array
  len   int
  cap   int
}
複製代碼

此結構的零值將暗示len和cap的值爲0,而且指向內存的指針array,保存切片背後數組的內容,其值也爲nil。這意味着您不須要顯式make切片,您只需聲明它便可。

func main() {
  // s := make([]string, 0)
  // s := []string{}
  var s []string

  s = append(s, "Hello")
  s = append(s, "world")
  fmt.Println(strings.Join(s, " "))
}
複製代碼

NOTE:var s []string看起來和上面被註釋掉的兩行很像,但又不徹底相同。要判斷值爲nil的切片和長度爲零的切片的區別是能夠辦到的,下面的代碼將輸出false:

func main() {
	var s1 = []string{}
	var s2 []string
	fmt.Println(reflect.DeepEqual(s1, s2))
}
複製代碼

一個意外可是有用的驚喜是未初始化的指針——nil指針,您能夠在nil值的類型上調用方法,這能夠簡單地用於提供默認值。

type Config struct {
	path string
}

func (c *Config) Path() string {
	if c == nil {
		return "/usr/home"
	}
	return c.path
}

func main() {
	var c1 *Config
	var c2 = &Config{
		path: "/export",
	}
	fmt.Println(c1.Path(), c2.Path())
}
複製代碼

4.5避免包級別的狀態

編寫可維護的程序的一個關鍵方面是鬆耦合——更改一個包,應該把對沒有直接依賴它的包的影響降到最低。

在 Go 中有兩種很好的方法能夠實現鬆散耦合:

  • 使用接口來描述函數或方法所需的行爲。
  • 避免使用全局狀態。

在 Go 中,咱們能夠在函數或方法範圍內聲明變量,也能夠在包的範圍內聲明變量。當變量是公開的,標識符首字母爲大寫,那麼其範圍其實是整個程序——任何包均可以在任什麼時候候觀察到它的類型和存儲的內容。

可變的全局狀態在程序的獨立部分之間引入了緊耦合,由於全局變量對於程序中的每一個函數都是隱匿的參數!若是全局變量的類型變化了,那麼任何依賴該變量的函數將會被打破。程序其餘任何部分對變量值的修改,都將致使依賴該變量狀態的函數被打破。

Austin Luo:全局變量對每一個函數都是可見的,但開發者可能意識不到全局變量的存在(即隱匿的參數),即便意識到並使用了全局變量,也可能意識不到該變量可能在別處被修改,致使全局變量的使用不可靠,依賴該變量狀態(值)的函數被打破。

若是您想減小全局變量帶來的耦合,那麼:

  1. 將相關變量做爲字段移動到須要它們的結構上。
  2. 使用接口來減小行爲與該行爲的實現之間的耦合。

5 項目結構

讓咱們來看看多個包合併在一塊兒組成項目的狀況。一般這應該是一個單獨的 git 倉庫,但在未來, Go 開發者將交替使用 moduleproject

和包同樣,每一個項目也應該有一個清晰的目的。若是您的項目是個庫,那麼它應該只提供一個東西,好比 XML 解析,或者日誌記錄。您應該避免將多個不一樣的目的混雜在同一個項目中,這有助於避免common庫的出現。

小竅門:根據個人經驗,common 庫與其最大的消費者(使用者)緊密相連,這使得在不鎖定步驟的狀況下單獨升級common或者消費者以進行升級或者修復變得很困難,從而帶來不少不相關的更改和 API 破壞。

若是您的項目是一個應用程序,好比您的 Web 應用,Kubernetes 控制器等等,那麼在您的項目中可能有一個或多個 main 包。好比,我維護的那個 Kubernetes 控制器裏有一個單獨的 cmd/contour 包,用來提供到 Kubernetes 集羣的服務部署,以及用於調試的客戶端。

5.1 考慮更少、更大的包

對於從其餘語言過渡到 Go 的程序員來講,我傾向於在代碼審查中提到的一件事是,他們傾向於過分使用包。

Go 沒有提供創建可見性的詳細方法:好比 Java 的 publicprotectedprivate和隱式 default 訪問修飾符,也沒有至關於 C++ 的friend類的概念。

在 Go 中咱們只有兩種訪問修飾符,公開和私有,這由標識符首字母的大小寫決定。若是標識符是公開的,命名首字母就是大寫的,則這個標識符能夠被其餘任何 Go 包引用。

注意:您可能聽到有人說導出和非導出,那是公開和私有的同義詞。

鑑於對包裏的符號可見性控制手段的有限,Go 程序員要怎麼作才能避免建立過於複雜的包層次結構呢?

小竅門:除 cmd/internal/ 以外,每一個包都應該包含一些源代碼。

我反覆建議的是偏向更少、更大的包。您的默認選項並非建立新的包,那將致使爲了建立寬而淺的 API 平面時您不得不公開太多的類型。

接下來的幾節讓咱們更詳細地探討這些建議。

小竅門:來自 Java? 若是您有開發 Java 或 C# 的背景,考慮這樣的經驗規則:一個 Java 包等效於一個獨立的 .go 源文件;一個 Go 包等效於整個 Maven 模塊或 .NET 程序集。

5.1.1 經過 import 語句將代碼整理到多個文件中

若是您根據包提供給調用者的功能來整理包,那麼在 Go 包裏整理源文件是否是也應該按相同的方式?您如何知道何時您應該將一個 .go 文件拆分紅多個文件?您如何知道是否是過度拆分,而應當考慮整合多個 .go 文件?

這裏是我用到的一些經驗規則:

  • 從單一的 .go 文件開始,而且使用與包相同的名字。好比包 http 的第一個文件應該是 http.go,而且放到名爲 http 的文件夾中。
  • 隨着包的逐漸增加,您能夠根據職責拆分不一樣的部分到不一樣的文件。例如,將 RequestResponse 類型拆分到 message.go 中,將 Client 類型拆分到 client.go 中,將 Server 類型拆分到 server.go 中。
  • 若是您發現您的文件具備很類似的 import 聲明時,考慮合併它們,不然肯定二者的具體差別並優化重構它們。
  • 不一樣的文件應該負責包的不一樣區域。messages.go 可能負責網絡相關的 HTTP 請求和響應編組,http.go 可能包含低級網絡處理邏輯,client.goserver.go 實現 HTTP 請求建立或路由的業務邏輯,等等。

小竅門:源文件名應當考慮名詞。 注意:Go 編譯器並行編譯各個包。在包中,Go 編譯器並行地編譯各個函數(方法在 Go 中只是花哨的函數)。修改包源碼中代碼的排列分佈不影響編譯時間。

5.1.2 內部測試優於外部測試

Go 工具集容許您在兩處編寫包的測試。假設您的包名是 http2,您能夠使用 package http2 聲明並編寫一個 http2_test.go 文件,這樣作將會把 http2_test.go 中的代碼當成 http2 包的一部分編譯進去。這一般稱爲內部測試。

Go 工具集一樣支持一個以 test 結尾的特定聲明的包,例如 package http_test,即便這些測試代碼不會被視爲正式代碼同樣編譯到正式的包裏,而且他們有本身獨立的包名,也容許您的測試文件和源碼文件同樣放置在一塊兒。這容許讓您像在外部另一個包裏調用同樣編寫測試用例,這咱們稱之爲外部測試。

在編寫單元測試時我推薦使用內部測試。這讓您能夠直接測試每一個函數或方法,避免外部測試的繁文縟節。 可是,您應該把 Example 測試函數放到外部測試中。這確保了在 godoc 中查看時,示例具備適當的包前綴,而且能夠輕鬆地進行復制粘貼。

小竅門:避免複雜的包層次結構,剋制分類的渴望 只有一個例外,這咱們將在後面詳述。對於 Go 工具集來說,Go 包的層次結構是沒有意義的。例如,net/http 並非 net 的子或子包。 若是您建立了不包含任何 .go 文件的中間目錄,則不適用此建議。

5.1.3 使用 internal 包收斂公開的 API 表面

若是您的項目包含多個包,則可能有一些導出的函數——這些函數旨在供項目中的其餘包使用,卻又不打算成爲項目的公共 API 的一部分。若是有這樣的狀況,則 go 工具集會識別一個特殊的文件夾名——非包名—— internal/,這用於放置那些對當前項目公開,但對其餘項目私有的代碼。

要建立這樣的包,把代碼放置於名爲 internal/ 的目錄或子目錄便可。 go 命令發現導入的包中包含 internal 路徑,它就會校驗執行導入的包是否位於以 internal 的父目錄爲根的目錄樹中。

例如,包 .../a/b/c/internal/d/e/f 只能被根目錄樹 .../a/b/c 中的代碼導入,不能被 .../a/b/g 或者其餘任何庫中的代碼導入。^5

5.2確保 main 包越小越好

main 函數和 main 包應當只作儘量少的事情,由於 main.main 其實是一個單例,整個應用程序都只容許一個 main 函數存在,包括單元測試。

因爲 main.main 是一個單例,所以 main.main 的調用中有不少假定,而這些假定又只在 main.mainmain.init 期間調用,而且只調用一次。這致使很難爲 main.main 中的代碼編寫單元測試,所以您的目標應該是將您的業務邏輯從主函數中移出,最好是壓根從主程序包中移出。

Austin Luo:這裏主要是講,因爲整個程序(包括單元測試在內)只容許存在一個 main.main,所以在 main.main 中編寫過多的代碼將致使這些代碼很難被測試覆蓋,所以應當將這些代碼從 main.main 中——甚至從 main 包中——獨立出來,以便可以寫單元測試進行測試。(文中的「假定」是針對測試而言,「假定」 main 中的代碼能夠正常運行。) 小竅門:main 應當解析標識,打開數據庫鏈接,初始化日誌模塊等等,而後將具體的執行交給其餘高級對象。

6 API設計

今天給出的最後一個設計建議是我認爲最重要的一個。

到此爲止我給出的全部建議,也僅僅是建議。這是我寫 Go 程序時遵照的方式,但也並無強制推行到代碼評審中。 可是,在審查 API 時,我就不太寬容了。由於以前我所說的一切均可以在不破壞向後兼容性的狀況下獲得修正,他們大多隻是實施細節而已。

但說到包的開放 API,在初始設計中投入大量精力是值得的,由於後續的更改將是破壞性的,特別是對於已經使用 API 的人來講。

6.1 設計難以被誤用的 API

APIs should be easy to use and hard to misuse. — Josh Bloch ^3 API 應當易用而且難以被誤用 若是您從這個演講中得到任何收益,那就應該是 Josh Bloch 的這個建議。若是 API 很難用於簡單的事情,那麼 API 的每次調用都會很複雜。當 API 的實際調用很複雜時,它將不那麼明顯,更容易被忽視。

6.1.1 警戒具備多個相同類型參數的函數

一個看起來很簡單,但實際很難正確使用的 API 的例子,就是具備兩個及以上的相同類型參數的狀況。讓咱們來對好比下兩個函數簽名:

func Max(a, b int) int
func CopyFile(to, from string) error
複製代碼

這兩個函數有什麼不一樣?很顯然一個是返回兩個數的最大數,另外一個是複製文件,但這都不是重點。

Max(8, 10) // 10
Max(10, 8) // 10
複製代碼

Max是可交換的,參數的順序可有可無,8 和 10 比較不管如何都是 10 更大,不管是 8 與 10 比較,仍是 10 與 8 比較。 可是,對於 CopyFile 就不具備這樣的特性了:

CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")
複製代碼

哪條語句將 presentation.md 複製了一份,哪條語句又是用上週的版本覆蓋了 presentation.md ?沒有文檔說明,您很難分辨。代碼評審者在沒有文檔時也對您參數傳入的順序是否正確不得而知。

一個可行的解決方案是,引入一個幫助類,用來正確地調用 CopyFile

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")
}
複製代碼

這樣 CopyFile 就老是能夠被正確地調用——這也能夠經過單元測試肯定,也能夠被設置爲私有,進一步下降了誤用的可能性。

小竅門:具備多個相同類型參數的 API 很難被正確使用。

6.2 針對默認用例設計 API

幾年前我作過一次關於使用功能選項^7使 API 在默認用例時更易用的報告^6。 本演講的主旨是您應該爲常見用例設計 API。另外一方面,您的 API 不該要求調用者提供那些他們不關心的參數。

#####6.2.1 不鼓勵使用 nil 做爲參數

我講述本章開宗明義時建議您不要強迫 API 的調用者在他們不關心這些參數意味着什麼的狀況下爲您提供那些參數。當我說針對默認用例的設計 API 時,這就是個人意思。

這裏有個來自 net/http 包的示例:

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 有兩個參數,一個 TCP 地址用來監聽傳入鏈接,一個 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)
複製代碼

兩個方式都作徹底同樣的事情。

這種 nil 的行爲是病毒式的。在 http 包中一樣有個 http.Serve 幫助類,您能夠合理地想象 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)
}
複製代碼

由於ListenAndServe容許調用者爲第二個參數傳遞nil,因此http.Serve也支持這種行爲。事實上,http.Serve 是「當 handlernil,則使用 DefaultServeMux」這個邏輯的一個實現。容許其中一個參數傳入 nil 可能致使調用者覺得他們能夠給兩個參數都傳入 nil(Austin Luo:調用者可能想,既然第二個參數有默認實現,那第一個參數可能也有),但像這樣調用:

http.Serve(nil, nil)
複製代碼

將致使一個醜陋的 panic 。

小竅門:在函數簽名中不要混用可爲 nil 和不可爲 nil 的參數。

http.ListenAndServe 的做者嘗試在常規情況時讓 API 的使用者更輕鬆,但可能反而致使這個包難於被安全地使用。 顯示地指定 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)
複製代碼

小竅門:認真考慮幫助類將節省程序員的時間。清晰比多個選擇好。 小竅門:避免公開只用於測試的參數 避免公開導出僅在測試做用域上具備不一樣值的 API。相反,使用 Public 包裝隱藏這些參數,使用在測試做用域的幫助類來設置測試範圍中的屬性。

6.2.2 首選可變參數(var args)而非切片參數

編寫一個處理切片的函數或方法是很常見的:

func ShutdownVMs(ids []string) error
複製代碼

這僅僅是我舉的一個例子,但在我工做中更加常見。像這樣的簽名的問題是,他們假設被調用時會有多個實體。可是,我發現不少時候這些類型的函數卻只有一個參數,爲了知足函數簽名的要求,它必須在一個切片內「裝箱」。(Austin Luo:如示例,函數定義時預期會有多個 id,但實際調用時每每只有一個 id,爲了知足前面,必須構造一個切片,並把 id 裝進去。)

此外,因爲 ids 是個切片,您能夠向函數傳入一個空的切片甚至 nil,編譯器也會容許。這就增長了更多的測試用例,由於您應當覆蓋這些場景。

爲構造一個這類型的 API 的例子,最近我重構了一條邏輯,若是一組參數中至少有一個非零則要求我設置一些額外的字段。這段邏輯看起來像這樣:

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
}
複製代碼

這使我可以向讀者明確執行內部塊的條件:

if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
        // apply the non zero parameters
}
複製代碼

但對於 anyPositive 仍是有一個問題,有人可能會意外地像這樣調用它:

if anyPositive(){...}
複製代碼

在這種狀況下anyPositive會返回false,由於它將不會執行迭代並當即返回false。這還不是世界上最糟糕的事情——(更糟糕的是)沒有傳入參數時這段代碼的邏輯將會變成「anyPositive是否返回true?」。 然而,假如能夠這樣那就更好了:更改 anyPositive 的簽名,使得強制調用者應該傳遞至少一個參數。咱們能夠像這樣組合常規參數和可變參數:

func anyPositive(first int, rest ...int) bool {
  if first > 0 {
return true
  }
  for _, v := range rest {
if v > 0 {
return true
}
  }
  return false
}
複製代碼

如今anyPositive的調用就不能少於一個參數了。

6.3 讓函數自身定義它所需的行爲

假設咱們有個將文檔保存寫入磁盤的工做任務。

// Save將doc的內容寫入文件f。
func Save(f * os.File,doc * Document)錯誤
複製代碼

我能夠這樣描述這個函數,Save,它以一個 *os.File 做爲目標來保存寫入 Document。但這有一些問題。

簽名 Save 排除了將數據寫入網絡位置的可能。假設網絡存儲成爲後續的需求,可能不得不更改函數簽名,從而影響其全部調用者。

Save 也對測試不友好,由於這是直接對磁盤的文件進行操做。所以,爲了驗證其操做,測試用例不得不在文件被寫入以後從新去讀取寫入的內容。並且我還必須確保 f 最終從臨時位置被刪除。 同時 *os.File 也定義了不少與 Save 無關的方法,好比讀取目錄,檢查一個路徑是否爲符號連接等。若是 Save 函數的簽名只描述 *os.File 的一部分就更好了。

咱們能夠怎麼作呢?

// Save writes the contents of doc to the supplied
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error
複製代碼

使用 io.ReadWriteCloser 咱們能夠遵循接口隔離原則從新定義 Save ,從而得到一個更常規的文件操做接口。 有了這個改變,io.ReadWriteCloser 接口的任何實現均可以替代前文的 *os.File

這使得 Save 的應用更加普遍,而且向 Save 的調用者澄清了哪些 *os.File 類型的方法與其操做相關。 而且,做爲 Save 函數做者,我再也不能調用 *os.File 其餘那些不相關方法,它們都被 io.ReadWriteCloser 接口隱藏到了背後。

咱們能夠針對接口隔離原則談得更深刻些。 首先,若是 Save 遵循單一職責原則,它不太可能讀取它剛剛編寫的文件以校驗其內容——這應該是另外一段代碼的責任。

// Save writes the contents of doc to the supplied
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
複製代碼

所以,咱們能夠將傳遞給 Save 的接口縮小到只是寫和關閉兩個方面。

其次,經過 Save 附帶提供一種關閉其流的機制(Austin Luo:因爲 io.WriteCloser 的存在,Save 隱含了關閉流的含義)。咱們繼承了這種機制,使其仍然看起來像一個文件,這就提出了在什麼狀況下 wc 會被關閉的問題。

可能 Save 會無條件地調用 Close,或者在成功的狀況下才調用 Close。 這給 Save 的調用者帶來一個問題,那就是但願在寫入文檔以後再向數據流寫入其餘數據時怎麼辦?

// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error
複製代碼

一個更好的解決方案是,從新定義 Save,只持有一個 io.Writer,將除了向數據流寫入數據以外的其餘全部職責都徹底剝離出來。 經過在 Save 函數上遵循接口隔離原則,其結果是實際需求的最核心描述同時做爲一個函數——它只須要一個可寫的對象——而且是最一般的狀況,咱們如今能夠使用 Save 來向任何 io.Writer 的實現保存數據。

7 錯誤處理

我已經作了好幾場關於錯誤處理的演講,在個人博客裏也寫了不少相關的內容,昨天的那一節我也講了不少了,所以我不打算再贅述了。

7.1 經過消除錯誤來消除錯誤處理

您昨天可能聽了個人講演,我談到了關於改進錯誤處理的建議草案。可是您知道有什麼是比改進錯誤處理語法更好的嗎?那就是根本不用處理錯誤。

注意:我並非說「移除您的錯誤處理」。我建議的是,修改您的代碼,從而無需處理錯誤。 本節是從 John Ousterhout 的新書《A philosophy of Software Design》^9中獲得的啓示。其中一章是「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
}
複製代碼

因爲咱們要遵循上一節的建議,CountLines持有了一個 io.Reader,而非 *File——提供要計數內容的 io.Reader 是調用者的職責。 咱們構造了一個bufio.Reader,並將它放到循環中調用ReadString方法,累加一個計數器,直到文件末尾,而後咱們返回讀取到的行數。 至少這是咱們指望的的代碼,但這個函數由於錯誤處理變得更加複雜。例如,這裏有個奇怪的結構:

_, err = br.ReadString('n')
lines++
if err != nil {
  break
}
複製代碼

咱們在判斷錯誤以前累加了計數——這看起來很怪異。 我之因此寫成這樣,是由於ReadString在遇到換行符以前若是遇到文件結尾則會返回一個錯誤,若是文件中沒有最終換行符,則會發生這種狀況。 爲了修復這個問題,咱們從新排列邏輯以累加行數,而後查看是否須要退出循環。

注意:這個邏輯依然不夠完美,您能發現 bug 嗎?

錯誤尚未檢查完畢。ReadString在遇到文件末尾時會返回io.EOF。這是符合預期的,ReadString須要某種方式「叫停,後面沒有更多的東西可讀取了」。所以在咱們向CountLine的調用者返回錯誤以前,咱們須要檢查錯誤不是io.EOF,而且在這種狀況下才將其進行傳播,不然咱們返回 nil 說一切正常。 Russ Cox 覺察到錯誤處理可能會模 糊函數操做,我想這就是個很好的例子。讓咱們來看一個優化的版本:

func CountLines(r io.Reader) (int, error) {
  sc := bufio.NewScanner(r)
  lines := 0
  for sc.Scan() {
lines++
  }
  return lines, sc.Err()
}
複製代碼

這個優化的版本選擇使用 bufio.Scanner 而不是 bufio.Reader。 在 bufio.Scanner 的封裝下使用 bufio.Reader,但它提供了一個很好的抽象層,幫助咱們移除了 CountLines 操做模糊不清的錯誤。

注意:bufio.Scanner 能夠根據任何模式掃描,但默認只查找換行。 sc.Scan() 這個方法,在匹配到一行文本而且沒有遇到錯誤時會返回 true,所以,for 循環會在遇到文件結尾或者遇到錯誤時退出。類型 bufio.Scanner 會記錄它遇到的第一個錯誤,一旦退出,咱們能夠使用 sc.Err() 方法獲取到這個錯誤。 最後,sc.Err() 會合理處理 io.EOF,而且在遇到文件結尾但沒有其餘錯誤時,將錯誤轉化爲 nil。 小竅門:當您發現本身遇到難以消除的錯誤時,請嘗試將某些操做提取到幫助類中。

7.1.2 寫入響應

個人第二個例子受到了博客文章「Errors are values」^10的啓發。 以前的講演中咱們已經看過如何打開、寫入和關閉文件。錯誤處理還存在,但不是那麼難以消除,咱們能夠使用 ioutil.ReadFileioutil.WriteFile 來封裝。可是當咱們處理低級別的網絡協議時,有必要經過 I/O 來構建響應,這就讓錯誤處理可能變得重複。考慮構建 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
}
複製代碼

首先咱們使用 fmt.Fprintf 構造了狀態行而且檢查了錯誤。而後爲每一個請求頭寫入鍵和值,一樣檢查了錯誤。最後咱們使用 rn 終結了請求頭這一段,仍然檢查了錯誤。接下來複制響應體到客戶端。最後,儘管咱們不用檢查 io.Copy 的錯誤,但咱們也須要將 io.Copy 的雙返回值轉換爲 WriteResponse 所需的單返回值。 這有太多的重複工做了。咱們能夠經過引入一個小的封裝類 errWriter 來讓這件事變得更容易。 errWriter 知足 io.Writer 的契約,所以它能夠用來包裝現有的 io.WritererrWriter 將寫入傳遞給底層的 Writer,直到檢測到錯誤,從這開始,它會丟棄任何寫入並返回先前的錯誤。

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 替換 WriteResponse 能夠顯着提升代碼的清晰度。每一個操做再也不須要用錯誤檢查來自我修復。經過檢查 ew.err 字段來將報告錯誤移動到函數的末尾,同時也避免由於 io.Copy 的多返回值而引發惱人的轉換。

7.2 錯誤只處理一次

最後,我想提一下您應該只處理一次錯誤。處理錯誤意味着檢查錯誤值並作出單一決定。

// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
        w.Write(buf)
}
複製代碼

若是錯誤您一次都不處理,那您就忽略了它。就像咱們看到的這樣,w.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 產生了一個錯誤,則會在日誌文件中寫一行日誌,記錄錯誤發生的文件和代碼行,而且錯誤又同時被返回給了調用者,調用者又可能去記錄日誌,繼續返回,直至回溯到程序的頂部。 調用者可能也會作一樣的事,

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
複製代碼

但在程序的頂部,您獲得了一個沒有上下文的原始錯誤,

err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF
複製代碼

我想進一步深刻研究這一點,由於我不認爲記錄而且返回錯誤僅僅是我的偏好的問題。

func WriteConfig(w io.Writer, conf \*Config) error {
  buf, err := json.Marshal(conf)
  if err != nil {
log.Printf("could not marshal config: %v", err)
// oops, forgot to return
  }
  if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
  }
  return nil
}
複製代碼

我看到的不少問題是程序員忘記在錯誤處返回。正如咱們以前談到的那樣,Go 風格應當使用保護條款,檢查函數進行下去的前提條件,並提早返回。

在這個示例中,做者處理了錯誤,記錄了日誌,但忘記返回,這將致使一個難以覺察的 bug。

在 Go 的錯誤處理契約中,若是出現錯誤,您不能對其餘返回值的內容作出任何假設。就像上例中若是 JSON 反序列化失敗,buf 的內容未知,可能什麼都不包含,但包含了 1/2 的 JSON 片斷會更糟糕。

由於程序員在檢查和日誌記錄了錯誤以後忘記返回,一個混亂的緩衝區被傳遞給了 WriteAll,它又可能執行成功,這樣配置文件就會被錯誤地覆蓋了。但此時函數會正常返回,而且發生問題的惟一跡象只是單個日誌行記錄了 JSON 編碼失敗,而不是編寫配置文件失敗。

7.2.1 向錯誤添加上下文

這個 bug 的發生是由於做者嘗試向錯誤消息添加上下文信息。他們試圖給本身留下一個線索,指引他們回到錯誤的源頭。

讓咱們看看使用 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
}
複製代碼

經過將錯誤的註釋與返回組合到一行,則以就更難以忘記返回錯誤,從而避免意外繼續。 若是寫文件時發生一個 I/O 錯誤,錯誤對象的 Error() 方法將會報告以下信息: could not write config: write failed: input/output error

7.2.2 使用 github.com/pkg/errors 包裝錯誤

fmt.Errorf 模式適用於提示錯誤信息,但其代價是原始的錯誤類型被掩蓋了。我認爲,將錯誤視爲不透明的值對於生成鬆散耦合的軟件很重要,因此若是對錯誤值所作的惟一事情是以下兩個方面的話,則原始錯誤是什麼類型就可有可無了。

  1. 檢查是否爲 nil
  2. 打印或記錄日誌

可是,在某些場景,可能並不常見,您確實須要恢復原始錯誤。在這種狀況下,您能夠使用相似個人 errors 包來備註這樣的錯誤。

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)
	}
}
複製代碼

如今報告的錯誤將會是很好的 K&D ^11 風格的錯誤: 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
複製代碼

使用 errors 包讓您得能夠以人和機器都能檢測到的方式向錯誤添加上下文。若是您昨天來看了個人講演,就會知道 error 的包裝正在進入即將發佈的 Go 版本的標準庫。

8 併發

咱們選擇 Go 開發項目一般是由於其併發的特性。Go 團隊已經不遺餘力使 Go 中的併發性廉價(在硬件資源方面)並具備高性能,可是使用 Go 的併發性寫出既不高性能也不可靠的代碼仍然是可能的。在我即將離開的時候,我想留下一些關於避免併發特性帶來的陷阱的建議。

Go 特性支持的第一類併發是針對通道、select 語句和 go 語句的。若是你從書籍或者培訓課程中正式地學習過,你可能注意到併發這一節老是在最後纔會講到。這裏也不例外,我選擇最後纔講併發,好像它是對於 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 {
	}
}
複製代碼

這段代碼按咱們預期在執行,他開啓了一個簡單的 Web 服務。但它同時又幹了些別的事情,那就是在一個無限循環中浪費 CPU。這是由於main的最後一行for {}循環阻塞了主的協程,由於它不作任何輸入輸出,也不等待鎖,也不在通道上作發送或接收,或以其餘方式與調度程序通訊。

因爲 Go 運行時主要是協同安排的,所以該程序將在單個 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()
	}
}
複製代碼

這可能看起來很愚蠢,但這是我看到的最一般的解決方案。這是不瞭解根本問題的癥結所在。

如今,若是你對 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 語句都會永遠阻塞在那。這是個頗有用的性質,由於如今咱們不想僅僅由於調用runtime.GoSched()就讓整個 CPU 都「旋轉」起來。但這樣作,咱們只治了標,沒有治本。 我想向你提出另外一種解決方案,但願這一方案已經被採用了。與其讓http.ListenAndServe在一個協程中執行並帶來一個「主協程中應該作什麼」的問題,不如簡單地由主協程本身來執行http.ListenAndServe

小竅門:Go 程序的 main.mian 函數退出,則 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")
  })
  if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
  }
}
複製代碼

總之,這是個人第一個簡易:若是你的協程在其餘協程返回結果以前什麼事都不能幹,一般就應該直接了當地本身作這件事,而不是委託其餘協程去作。 這一般也消除了將結果從協程引導回其發起者所需的大量狀態跟蹤和通道操做。

小竅門:許多 Go 程序員濫用協程,特別是初學者。與生活中的全部事情同樣,適度是成功的關鍵。

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
複製代碼

首先,顯著的區別是,第一個例子讀取目錄到切片,而後將整個切片返回,不然若是有問題則返回一個錯誤。這是同步發生的,ListDirectory的調用者將被阻塞直到整個目錄被讀完。依賴於目錄有多大,這個過程可能持續很長時間,也可能由於構建一個目錄條目名稱的切片,而分配大量的內存。

讓咱們來看看第二個例子。這更一個更像 Go,ListDirectory返回了一個傳輸目錄條目的通道,當通道關閉時,代表沒有更多目錄條目了。因爲通道信息發生在ListDirectory返回以後,ListDirectory內部可能開啓了一個協程。

注意:第二個版本實際上沒有必要真的使用一個協程。它能夠分配一個足以保存全部目錄條目而不阻塞的通道,填充通道,關閉它,而後將通道返回給調用者。但這不太可能,由於這會消耗大量內存來緩衝通道中的全部結果。

通道版本的ListDirectory還有兩個進一步的問題:

  • 經過使用通道的關閉做爲沒有更多項目要處理的信號,ListDirectory在中途遇到錯誤就沒法告知調用者返回的集合是不完整的。調用者也沒法區分空目錄和一讀取就產生錯誤的狀況,這兩種結果對於ListDirectory返回的通道來講都是當即關閉。
  • 調用者必須持續讀取通道的內容直到通道關閉,由於這是讓調用者知道協程已經結束的惟一辦法。這是對ListDirectory的使用的一個嚴重限制。調用者必須花時間從通道讀取數據,哪怕調用者已經接收到它想要的信息。就須要使用大量內存的中型到大型目錄而言,它可能更有效,但這種方法並不比原始的基於切片的方法快。

以上兩個實現中的問題,其解決方案是使用回調。一個在每一個目錄條目上執行的函數。

func ListDirectory(dir string, fn func(string))
複製代碼

絕不奇怪,filepath.WalkDir就是這麼作的。

小竅門:若是你的函數開啓了一個協程,那麼你必須給調用者提供一箇中止協程的途徑。將異步執行函數的決策留給該函數的調用者一般更容易。

8.3 不要啓動一個永不中止的協程

上一個例子演示了沒有必要的狀況下使用協程。但使用 Go 的驅動緣由之一是該語言提供的第一類併發功能。實際上,在許多狀況下,您但願利用硬件中可用的並行性。爲此,你必須使用協程。 這個簡單的應用,在兩個不一樣的端口上提供 http 服務,端口 8080 用於應用自己的流量,8081 用於訪問 /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 分離出來。咱們照樣遵循了上面的建議,把 serveAppserveDebug 的併發性留給了調用者。 可是這個程序有一些可操做性上的問題。若是serveApp返回則main.main會返回並致使程序關閉,最終由您正在使用的任何進程管理器從新啓動。

小竅門:正如函數的併發性留給調用者同樣,應用應該將狀態監視、重啓留給程序的喚起者。不要讓你的應用程序擔負重啓自身的責任,這是一個最好從應用程序外部處理的過程。 可是,serveDebug 是在另外一個協程中執行的,若是它退出,也僅僅是這個協程自身退出,程序的其餘部分將繼續運行。因爲/debug處理程序中止工做,您的操做人員會很不高興地發現他們沒法在應用程序中獲取統計信息。 咱們要確保的是,負責服務此應用程序的任何協程中止,都關閉應用程序。

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 {}
}
複製代碼

如今咱們經過必要時調用 log.Fatal 來檢查 serverAppserveDebugListenAndServe 返回的錯誤。因爲兩個處理器都是在協程中運行,咱們使用 select{} 來阻塞主協程。 這種方法存在許多問題:

  1. 若是 ListenAndServe 返回一個 nillog.Fatal不會被調用,則對應的 HTTP 服務會中止,而且應用程序不會退出。
  2. log.Fatal 會調用 os.Exit 無條件終止進程,defer 不會被調用,其餘協程不會被通知關閉,應用程序會中止。這會使得爲這些函數編寫測試用例變得很困難。

小竅門:只在 main.maininit 函數裏使用 log.Fatal

咱們須要的是,把任何錯誤都傳回協程的發起者,以便於咱們弄清楚爲何協程會中止,而且能夠乾淨地關閉進程。

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)
		}
	}
}
複製代碼

咱們能夠使用一個通道來收集協程返回的狀態。通道的大小與咱們要管理的協程數一致,從而使得向 done 通道發送狀態時不會被阻塞,不然這將阻塞協程的關閉,致使泄漏。

因爲沒有辦法安全地關閉 done 通道,咱們不能使用 for range 循環通道知道全部協程都上報了信息,所以咱們循環開啓協程的次數,這也等於通道的容量。 如今咱們有辦法等待協程乾淨地退出,而且記錄發生的日誌。咱們所需的僅僅是將一個協程的關閉信號,通知到其餘協程而已。

其結果是,通知一個 http.Server 關閉這事被引入進來。因此我將這個邏輯轉換爲輔助函數。serve 幫助咱們持有一個地址和一個 http.Handler,相似 http.ListenAndServe 以及一個用於觸發 Shutdown 方法的 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)
		}
	}
}
複製代碼

如今,每當咱們從 done 通道接收到一個值,就關閉 stop 通道,從而致使全部等待在這個通道上的協程關閉 http.Server。這將致使全部剩餘的 ListenAndServe 協程返回。一旦咱們啓動的協程中止,main.main 便返回繼而進程乾淨地中止了。

小竅門:本身寫這個邏輯是重複和微妙的。考慮相似這個包的東西,github.com/heptio/work… 它將爲你完成大部分工做。

【完】

參考連接

  1. gaston.life/books/effec…
  2. talks.golang.org/2014/names.…
  3. www.infoq.com/articles/AP…
  4. www.lysator.liu.se/c/pikestyle…
  5. speakerdeck.com/campoy/unde…
  6. www.youtube.com/watch?v=Ic2…
  7. medium.com/@matryer/li…
  8. golang.org/doc/go1.4#i…
  9. dave.cheney.net/2014/10/17/…
  10. commandcenter.blogspot.com/2014/01/sel…
  11. dave.cheney.net/2016/04/27/…
  12. www.amazon.com/Philosophy-…
  13. blog.golang.org/errors-are-…
  14. www.gopl.io/

整理自cloud.tencent.com/developer/a…

原文dave.cheney.net/practical-g…

相關文章
相關標籤/搜索