來源:cyningsun.github.io/08-03-2019/…html
在座的各位有誰把 code review 做爲平常工做的一部分?【整個房間舉起了手,鼓舞人心】。好的,爲何要進行 code review ?【有人高呼「阻止不良代碼」】git
若是代碼審查是爲了捕捉糟糕的代碼,那麼你如何知道你正在審查的代碼是好仍是糟糕?程序員
正如你可能會說「這幅畫很漂亮」或「這個房間很漂亮」,如今你能夠說「代碼很難看」或「源代碼很漂亮」,但這些都是主觀的。我正在尋找以客觀方式談論代碼好或壞的特徵。github
你在 code review 中可能會遇到如下這些糟糕代碼的特徵:json
這些詞是正向嗎?你是否樂於看到這些詞用於審覈您的代碼?數組
想必不會。promise
但這是一個進步,如今咱們能夠說「我不喜歡它,由於它太難修改」,或「我不喜歡它,由於我不知道代碼試圖作什麼」,但如何正向引導呢?網絡
若是有一些方法能夠描述糟糕的設計,以及優秀設計的特徵,而且可以以客觀的方式作到這一點,那不是很好嗎?app
2002年,Robert Martin 出版了他的書 Agile Software Development, Principles, Patterns, and Practices 其中描述了可重用軟件設計的五個原則,並稱之爲 SOLID
(英文首字母縮寫)原則:框架
這本書有點過期了,它所討論的語言是十多年前使用的語言。可是,也許 SOLID
原則的某些方面能夠給咱們提供些線索,關於怎樣談論一個精心設計的 Go 程序。
SOLID的第一個原則,S,是單一責任原則。
A class should have one, and only one, reason to change.
– Robert C Martin
如今 Go 顯然沒有 classses
- 相反,咱們有更強大的組合概念 - 可是若是你能回顧一下 class
這個詞的用法,我認爲此時會有必定價值。
爲何一段代碼只有一個改變的緣由很重要?嗯,就像你本身的代碼可能會改變同樣使人沮喪,發現您的代碼所依賴的代碼在您腳下發生變化更痛苦。當你的代碼必須改變時,它應該響應直接刺激做出改變,而不該該成爲附帶損害的受害者。
所以,具備單一責任的代碼修改的緣由最少。
描述改變一個軟件是多麼容易或困難的兩個詞是:耦合和內聚。
在軟件上下文中,內聚是描述代碼片斷之間天然相互吸引的特性。
爲了描述Go程序中耦合和內聚的單元,咱們可能會將談談函數和方法,這在討論 SRP
時很常見,可是我相信它始於 Go 的 package 模型。
SRP: Single Responsibility Principle
在 Go 中,全部的代碼都在某個 package 中,一個設計良好的 package 從其名稱開始。包的名稱既是其用途的描述,也是名稱空間前綴。Go 標準庫中的一些優秀 package 示例:
net/http
- 提供 http 客戶端和服務端os/exec
- 執行外部命令encoding/json
- 實現JSON文檔的編碼和解碼當你在本身的內部使用另外一個 pakcage 的 symbols 時,要使用 import
聲明,它在兩個 package 之間創建一個源代碼級的耦合。 他們如今彼此知道對方的存在。
這種對名字的關注可不是迂腐。命名不佳的 package 若是真的有用途,會失去羅列其用途的機會。
server
package 提供什麼? ..., 嗯,但願是服務端,可是它使用哪一種協議?private
package 提供什麼?我不該該看到的東西?它應該有公共符號嗎?common
package,和它的伴兒 utils
package 同樣,常常被發現和其餘'夥伴'一塊兒發現咱們看到全部像這樣的包裹,就成了各類各樣的垃圾場,由於它們有許多責任,因此常常毫無理由地改變。
在我看來,若是不說起 Doug McIlroy 的 Unix 哲學,任何關於解耦設計的討論都將是不完整的;小而鋒利的工具結合起來,解決更大的任務,一般是原始做者沒法想象的任務。
我認爲 Go package 體現了 Unix 哲學的精神。實際上,每一個 Go package 自己就是一個小的 Go 程序,一個單一的變動單元,具備單一的責任。
第二個原則,即 O,是 Bertrand Meyer
的開放/封閉原則,他在1988年寫道:
Software entities should be open for extension, but closed for modification.
– Bertrand Meyer, Object-Oriented Software Construction
該建議如何適用於21年後寫的語言?
package main
type A struct {
year int
}
func (a A) Greet() { fmt.Println("Hello GolangUK", a.year) }
type B struct {
A
}
func (b B) Greet() { fmt.Println("Welcome to GolangUK", b.year) }
func main() {
var a A
a.year = 2016
var b B
b.year = 2016
a.Greet() // Hello GolangUK 2016
b.Greet() // Welcome to GolangUK 2016
}
複製代碼
咱們有一個類型 A ,有一個字段 year 和一個方法 Greet。咱們有第二種類型,B 它嵌入了一個 A,由於 A 嵌入,所以調用者看到 B 的方法覆蓋了 A 的方法。由於A做爲字段嵌入B ,B能夠提供本身的 Greet 方法,掩蓋了 A 的 Greet 方法。
但嵌入不只適用於方法,還能夠訪問嵌入類型的字段。如您所見,由於A和B都在同一個包中定義,因此 B 能夠訪問 A 的私有 year 字段,就像在 B 中聲明同樣。
所以嵌入是一個強大的工具,容許 Go 的類型對擴展開放。
package main
type Cat struct {
Name string
}
func (c Cat) Legs() int { return 4 }
func (c Cat) PrintLegs() {
fmt.Printf("I have %d legs\n", c.Legs())
}
type OctoCat struct {
Cat
}
func (o OctoCat) Legs() int { return 5 }
func main() {
var octo OctoCat
fmt.Println(octo.Legs()) // 5
octo.PrintLegs() // I have 4 legs
}
複製代碼
在這個例子中,咱們有一個 Cat 類型,能夠用它的 Legs 方法計算它的腿數。咱們將 Cat 類型嵌入到一個新類型 OctoCat 中,並聲明 Octocats 有五條腿。可是,雖然 OctoCat 定義了本身的 Legs 方法,該方法返回5,可是當調用 PrintLegs 方法時,它返回4。
這是由於 PrintLegs 是在 Cat 類型上定義的。 它須要 Cat 做爲它的接收器,所以它會發送到 Cat 的 Legs 方法。Cat 不知道它嵌入的類型,所以嵌入時不能改變其方法集。
所以,咱們能夠說 Go 的類型雖然對擴展開放,但對修改是封閉的。
事實上,Go 中的方法只不過是圍繞在具備預先聲明形式參數(即接收器)的函數的語法糖。
func (c Cat) PrintLegs() {
fmt.Printf("I have %d legs\n", c.Legs())
}
func PrintLegs(c Cat) {
fmt.Printf("I have %d legs\n", c.Legs())
}
複製代碼
接收器正是你傳入它的函數,函數的第一個參數,而且由於Go不支持函數重載,OctoCat不能替代普通的Cat 。 這讓我想到了下一個原則。
由Barbara Liskov 提出的里氏替換原則粗略地指出,若是兩種類型表現出的行爲使得調用者沒法區分,則這兩種類型是可替代的。
在基於類的語言中,里氏替換原則一般被解釋爲,具備各類具體子類型的抽象基類的規範。 可是Go沒有類或繼承,所以沒法根據抽象類層次結構實現替換。
相反,替換是Go接口的範圍。在Go中,類型不須要指定它們實現特定接口,而是任何類型實現接口,只要它具備簽名與接口聲明匹配的方法。
咱們說在Go中,接口是隱式地而不是顯式地知足的,這對它們在語言中的使用方式產生了深遠的影響。
設計良好的接口更多是小型接口; 流行的作法是一個接口只包含一個方法。從邏輯上講,小接口使實現變得簡單,反之則很難。所以造成了由普通行爲的簡單實現組成的 package。
type Reader interface {
// Read reads up to len(buf) bytes into buf.
Read(buf []byte) (n int, err error)
}
複製代碼
這令我很容易想到了我最喜歡的 Go 接口 io.Reader
。
io.Reader
接口很是簡單; Read
將數據讀入提供的緩衝區,並將讀取的字節數和讀取期間遇到的任何錯誤返回給調用者。看起來很簡單,但很是強大。
由於 io.Reader
能夠處理任何表示爲字節流的東西,因此咱們幾乎能夠在任何東西上建立 Reader
; 常量字符串,字節數組,標準輸入,網絡流,gzip的tar文件,經過ssh遠程執行的命令的標準輸出。
而且全部這些實現均可以互相替代,由於它們實現了相同的簡單契約。
所以,適用於Go的里氏替換原則,能夠經過已故 Jim Weirich 的格言來歸納。
Require no more, promise no less.
– Jim Weirich
順利轉入」SOLID」第四個原則。
第四個原則是接口隔離原則,其內容以下:
Clients should not be forced to depend on methods they do not use. –Robert C. Martin
在Go中,接口隔離原則的應用能夠指的是,隔離功能完成其工做所需的行爲的過程。舉一個具體的例子,假設我已經完成了‘編寫一個將Document結構保存到磁盤的函數’的任務。
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error 複製代碼
我能夠定義此函數,讓咱們稱之爲 Save
,它將給定的 Document 寫入到 *os.File
。 可是這樣作會有一些問題。
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
的接口的規範縮小,僅寫入和關閉。
// Save writes the contents of doc to the supplied WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error 複製代碼
其次,經過向 Save
提供一個關閉其流的機制,咱們繼續這種機制以使其看起來像文件類型的東西,這就產生一個問題,wc
會在什麼狀況下關閉。Save
可能會無條件地調用 Close
,抑或在成功的狀況下調用 Close
。
這給 Save
的調用者帶來了問題,由於它可能但願在寫入文檔以後將其餘數據寫入流。
type NopCloser struct {
io.Writer
}
// Close has no effect on the underlying writer.
func (c *NopCloser) Close() error { return nil }
複製代碼
一個粗略的解決方案是定義一個新類型,它嵌入一個 io.Writer
並覆蓋 Close
方法,以阻止Save
方法關閉底層數據流。
但這樣可能會違反里氏替換原則,由於NopCloser實際上並無關閉任何東西。
// 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
的地方。
A great rule of thumb for Go is accept interfaces, return structs.
– Jack Lindamood
退一步說,這句話是一個有趣的模因,在過去的幾年裏,它滲透入 Go 思潮。
這個推特大小的版本缺少細節,這不是Jack的錯,但我認爲它表明了第一個正當有理的Go設計傳統
最後一個SOLID原則是依賴倒置原則,該原則指出:
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
– Robert C. Martin
可是,對於Go程序員來講,依賴倒置在實踐中意味着什麼呢?
若是您已經應用了咱們以前談到的全部原則,那麼您的代碼應該已經被分解爲離散包,每一個包都有一個明肯定義的責任或目的。您的代碼應該根據接口描述其依賴關係,而且應該考慮這些接口以僅描述這些函數所需的行爲。 換句話說,除此以外沒什麼應該要作的。
因此我認爲,在Go的上下文中,Martin所指的是 import graph 的結構。
在Go中,import graph 必須是非循環的。 不遵照這種非循環要求將致使編譯失敗,但更爲嚴重地是它表明設計中存在嚴重錯誤。
在全部條件相同的狀況下,精心設計的Go程序的 import graph 應該是寬的,相對平坦的,而不是高而窄的。 若是你有一個 package,其函數沒法在不借助另外一個 package 的狀況下運行,那麼這或許代表代碼沒有很好地沿 pakcage 邊界分解。
依賴倒置原則鼓勵您將特定的責任,沿着 import graph 儘量的推向更高層級,推給 main package 或頂級處理程序,留下較低級別的代碼來處理抽象接口。
回顧一下,當應用於Go時,每一個SOLID原則都是關於設計的強有力陳述,但綜合起來它們具備中心主題。
若是要總結一下本次演講,那可能就是這樣:interfaces let you apply the SOLID principles to Go programs
。
由於接口讓Go程序員描述他們的 package 提供了什麼 - 而不是它怎麼作的。換個說法就是「解耦」,這確實是目標,由於越鬆散耦合的軟件越容易修改。
正如Sandi Metz所說:
Design is the art of arranging code that needs to work today, and to be easy to change forever.
– Sandi Metz
由於若是Go想要成爲公司長期投資的語言,Go程序的可維護性,更容易變動,將是他們決策的關鍵因素。
最後,讓咱們回到我打開本次演講的問題; 世界上有多少Go程序員?這是個人猜想:
By 2020, there will be 500,000 Go developers.
- me
50萬Go程序員會用他們的時間作些什麼?好吧,顯然,他們會寫不少Go代碼,實話實說,並非全部的都是好的代碼,有些會很糟糕。
請理解,我如此說並不是殘酷,可是,在這個房間裏,每個有着其餘語言發展經驗的人——大家來自的語言,來到Go——從你本身的經驗中知道,這個預言有一點是真的。
Within C++, there is a much smaller and cleaner language struggling to get out.
– Bjarne Stroustrup, The Design and Evolution of C++
全部的程序員都有機會讓咱們的語言成功,依靠咱們的集體能力,不要把人們開始談論Go的事情弄得一團糟,就像他們今天對C++的笑話同樣。
嘲弄其餘語言的敘述過於冗長、冗長和過於複雜,總有一天會轉向GO,我不想看到這種狀況發生,因此我有一個請求。
Go程序員須要少談框架,多談設計。咱們須要中止不惜一切代價關注性能,轉而盡心盡力地專一於重用。
我想看到的是人們在談論如何使用咱們今天使用的語言,不管其選擇和限制,設計解決方案和解決實際問題。
我想聽到的是人們在談論如何以精心設計,解耦,重用,最重要的是響應變化的方式設計Go程序。
今天在座的各位都能聽到來自衆多演講者的演講,這太好了,但事實是,不管此次會議規模有多大,與Go生命週期中使用Go的人數相比,咱們只是一小部分。
所以,咱們須要告訴世界上其餘地方應該如何編寫好軟件。優秀的軟件,可組合的軟件,易於更改的軟件,並向他們展現如何使用Go進行更改。從你開始。
我但願你開始談論設計,也許使用我在這裏提出的一些想法,但願你能作本身的研究,並將這些想法應用到你的項目中。那我想要你:
由於經過作這些事情,咱們能夠創建一種Go開發人員的文化,他們關心設計用於持久的程序。
謝謝。