譯 | SOLID Go Design

來源:cyningsun.github.io/08-03-2019/…html

Code review

在座的各位有誰把 code review 做爲平常工做的一部分?【整個房間舉起了手,鼓舞人心】。好的,爲何要進行 code review ?【有人高呼「阻止不良代碼」】git

若是代碼審查是爲了捕捉糟糕的代碼,那麼你如何知道你正在審查的代碼是好仍是糟糕?程序員

正如你可能會說「這幅畫很漂亮」或「這個房間很漂亮」,如今你能夠說「代碼很難看」或「源代碼很漂亮」,但這些都是主觀的。我正在尋找以客觀方式談論代碼好或壞的特徵。github

Bad code

你在 code review 中可能會遇到如下這些糟糕代碼的特徵:json

  • Rigid - 代碼死板嗎?它是否有強類型或參數,以至難於修改?
  • Fragile - 代碼脆弱嗎?細微的改變是否會在代碼庫中引發不可估量的破壞?
  • Immobile - 代碼難以重構嗎?代碼只需敲敲鍵盤就能夠避免循環導入?
  • Complex - 有沒有代碼是爲了炫技,是否過分設計?
  • Verbose - 代碼使用費力嗎?當閱讀時,能看出來代碼在作什麼嗎?

這些詞是正向嗎?你是否樂於看到這些詞用於審覈您的代碼?數組

想必不會。promise

Good design

但這是一個進步,如今咱們能夠說「我不喜歡它,由於它太難修改」,或「我不喜歡它,由於我不知道代碼試圖作什麼」,但如何正向引導呢?網絡

若是有一些方法能夠描述糟糕的設計,以及優秀設計的特徵,而且可以以客觀的方式作到這一點,那不是很好嗎?app

SOLID

2002年,Robert Martin 出版了他的書 Agile Software Development, Principles, Patterns, and Practices 其中描述了可重用軟件設計的五個原則,並稱之爲 SOLID(英文首字母縮寫)原則:框架

  • 單一職責原則(Single Responsibility Principle)
  • 開放/封閉原則(Open / Closed Principle)
  • 里氏替換原則(Liskov Substitution Principle)
  • 接口隔離原則(Interface Segregation Principle)
  • 依賴倒置原則(Dependency Inversion Principle)

這本書有點過期了,它所討論的語言是十多年前使用的語言。可是,也許 SOLID 原則的某些方面能夠給咱們提供些線索,關於怎樣談論一個精心設計的 Go 程序。

單一職責原則(Single Responsibility Principle)

SOLID的第一個原則,S,是單一責任原則。

A class should have one, and only one, reason to change. – Robert C Martin

如今 Go 顯然沒有 classses - 相反,咱們有更強大的組合概念 - 可是若是你能回顧一下 class 這個詞的用法,我認爲此時會有必定價值。

爲何一段代碼只有一個改變的緣由很重要?嗯,就像你本身的代碼可能會改變同樣使人沮喪,發現您的代碼所依賴的代碼在您腳下發生變化更痛苦。當你的代碼必須改變時,它應該響應直接刺激做出改變,而不該該成爲附帶損害的受害者。

所以,具備單一責任的代碼修改的緣由最少。

Coupling & Cohesion

描述改變一個軟件是多麼容易或困難的兩個詞是:耦合和內聚。

  • 耦合只是一個詞,描述了兩個一塊兒變化的東西 —— 一個運動誘導另外一個運動。
  • 一個相關但獨立的概念是內聚,一種相互吸引的力量。

在軟件上下文中,內聚是描述代碼片斷之間天然相互吸引的特性。

爲了描述Go程序中耦合和內聚的單元,咱們可能會將談談函數和方法,這在討論 SRP 時很常見,可是我相信它始於 Go 的 package 模型。

SRP: Single Responsibility Principle

Package names

在 Go 中,全部的代碼都在某個 package 中,一個設計良好的 package 從其名稱開始。包的名稱既是其用途的描述,也是名稱空間前綴。Go 標準庫中的一些優秀 package 示例:

  • net/http - 提供 http 客戶端和服務端
  • os/exec - 執行外部命令
  • encoding/json - 實現JSON文檔的編碼和解碼

當你在本身的內部使用另外一個 pakcage 的 symbols 時,要使用 import 聲明,它在兩個 package 之間創建一個源代碼級的耦合。 他們如今彼此知道對方的存在。

Bad package names

這種對名字的關注可不是迂腐。命名不佳的 package 若是真的有用途,會失去羅列其用途的機會。

  • server package 提供什麼? ..., 嗯,但願是服務端,可是它使用哪一種協議?
  • private package 提供什麼?我不該該看到的東西?它應該有公共符號嗎?
  • common package,和它的伴兒 utils package 同樣,常常被發現和其餘'夥伴'一塊兒發現

咱們看到全部像這樣的包裹,就成了各類各樣的垃圾場,由於它們有許多責任,因此常常毫無理由地改變。

Go’s UNIX philosophy

在我看來,若是不說起 Doug McIlroy 的 Unix 哲學,任何關於解耦設計的討論都將是不完整的;小而鋒利的工具結合起來,解決更大的任務,一般是原始做者沒法想象的任務。

我認爲 Go package 體現了 Unix 哲學的精神。實際上,每一個 Go package 自己就是一個小的 Go 程序,一個單一的變動單元,具備單一的責任。

開放/封閉原則(Open / Closed Principle)

第二個原則,即 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 。 這讓我想到了下一個原則。

里氏替換原則(Liskov Substitution Principle)

由Barbara Liskov 提出的里氏替換原則粗略地指出,若是兩種類型表現出的行爲使得調用者沒法區分,則這兩種類型是可替代的。

在基於類的語言中,里氏替換原則一般被解釋爲,具備各類具體子類型的抽象基類的規範。 可是Go沒有類或繼承,所以沒法根據抽象類層次結構實現替換。

Interfaces

相反,替換是Go接口的範圍。在Go中,類型不須要指定它們實現特定接口,而是任何類型實現接口,只要它具備簽名與接口聲明匹配的方法。

咱們說在Go中,接口是隱式地而不是顯式地知足的,這對它們在語言中的使用方式產生了深遠的影響。

設計良好的接口更多是小型接口; 流行的作法是一個接口只包含一個方法。從邏輯上講,小接口使實現變得簡單,反之則很難。所以造成了由普通行爲的簡單實現組成的 package。

io.Reader
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」第四個原則。

接口隔離原則(Interface Segregation Principle)

第四個原則是接口隔離原則,其內容以下:

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設計傳統

依賴倒置原則(Dependency Inversion Principle)

最後一個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 或頂級處理程序,留下較低級別的代碼來處理抽象接口。

SOLID Go Design

回顧一下,當應用於Go時,每一個SOLID原則都是關於設計的強有力陳述,但綜合起來它們具備中心主題。

  • 單一職責原則,鼓勵您將功能,類型、方法結構化爲具備天然內聚的包; 類型屬於彼此,函數服務於單一目的。
  • 開放/封閉原則,鼓勵您使用嵌入將簡單類型組合成更復雜的類型。
  • 里氏替換原則,鼓勵您根據接口而不是具體類型來表達包之間的依賴關係。經過定義小型接口,咱們能夠更加確信,實現將忠實地知足他們的契約。
  • 接口隔離原則,進一步採用了這個想法,並鼓勵您定義僅依賴於他們所需行爲的函數和方法。若是您的函數僅須要具備單個接口類型的參數的方法,則該函數更可能只有一個責任。
  • 依賴倒置原則,鼓勵您按照從編譯時間到運行時間的時序,轉移 package 所依賴的知識。在Go中,咱們能夠經過特定 package 使用的import語句的數量減小看到了這一點。

若是要總結一下本次演講,那可能就是這樣: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程序。

… one more thing

今天在座的各位都能聽到來自衆多演講者的演講,這太好了,但事實是,不管此次會議規模有多大,與Go生命週期中使用Go的人數相比,咱們只是一小部分。

所以,咱們須要告訴世界上其餘地方應該如何編寫好軟件。優秀的軟件,可組合的軟件,易於更改的軟件,並向他們展現如何使用Go進行更改。從你開始。

我但願你開始談論設計,也許使用我在這裏提出的一些想法,但願你能作本身的研究,並將這些想法應用到你的項目中。那我想要你:

  • 寫一篇關於設計的博客文章。
  • 教一個關於設計的workshop。
  • 寫一本關於你學到的東西的書。
  • 明年再回到這個會議,談談你取得的成就。

由於經過作這些事情,咱們能夠創建一種Go開發人員的文化,他們關心設計用於持久的程序。

謝謝。

原文:SOLID Go Design

相關文章
相關標籤/搜索