SOLID Go Design - Go語言物件導向設計

田浩浩發表於2016-10-17

程式碼評審

為什麼要程式碼評審? 如果程式碼評審是要捕捉糟糕的程式碼,那麼你如何知道你審查的程式碼是好的還是糟糕的?

我在找一些客觀的方式來談論程式碼的好壞屬性。

糟糕的程式碼

你可能會在程式碼審查中遇到以下這些糟糕的程式碼:

  • Rigid - 程式碼是否死板?它是否有強型別或引數以至於修改起來很困難?
  • Fragile - 程式碼是否脆弱?對程式碼做輕微的改變是否就會引起程式極大的破壞?
  • Immobile - 程式碼是否很難重構?
  • Complex - 程式碼是否過於複雜,是否過度設計?
  • Verbose - 程式碼是否過於冗長而使用起來很費勁?當查閱程式碼是否很難看出來程式碼在做什麼?

當你做程式碼審查的時候是否會很高興看到這些詞語?

當然不會。

好的設計

如果有一些描述優秀的設計屬性的方式就更好了,不僅僅是糟糕的設計,是否能在客觀條件下做?

SOLID - 物件導向設計

在2002年,Robert Martin的Agile Software Development, Principles, Patterns, and Practices 書中提到了五個可重用軟體設計的原則 - "SOLID"(英文首字母縮略字):

這本書有點點過時,使用的語言也是十多年前的。但是,或許SOLID原則的某些方面可以給我們一個有關如何談論一個精心設計的Go語言程式的線索。

1) Single Responsibility Principle - 單一功能原則

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

現在Go語言顯然沒有classses - 相反,我們有更為強大的組合的概念 - 但是如果你可以看到過去class的使用,我認為這裡有其價值。

為什麼一段程式碼應該只有一個原因改變如此重要?當然,和你自己的程式碼要修改比較起來,發現自己程式碼所依賴的程式碼要修改會更令人頭疼。而且,當你的程式碼不得不要修改的時候,它應該對直接的刺激有反應,而不應該是一個間接傷害的受害者。

所以,程式碼有單一功能原則從而有最少的原因來改變。

  • Coupling & Cohesion - 耦合與內聚

    這兩個詞語描繪了修改一段軟體程式碼是何等的簡單或困難。

    Coupling - 耦合是兩個東西一起改變 - 一個移動會引發另一個移動。 Cohesion - 內聚是相關聯但又隔離,一種相互吸引的力量。

    在軟體方面,內聚是形容程式碼段自然吸引到另一個的屬性。

    要描述Go語言的耦合與內聚,我們可以要談論一下functions和methods,當討論單一功能原則時它們很常見,但是我相信它始於Go語言的package模型。

  • Pakcage命名

    在Go語言中,所有的程式碼都在某個package中。好的package設計始於他的命名。package名字不僅描述了它的目的而且還是一個名稱空間的字首。Go語言標準庫裡有一些好的例子:

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

在你自己的專案中使用其他pakcage時要用import宣告,它會在兩個package之間建立一個原始碼級的耦合。

  • 糟糕的pakcage命名

    關注於命名並不是在賣弄。糟糕的命名會失去羅列其目的的機會。

    比如說serverprivatecommonutils 這些糟糕的命名都很常見。這些package就像是一個混雜的場所,因為他們好多都是沒有原因地經常改變。

  • Go語言的UNIX哲學

    以我的觀點,涉及到解耦設計必須要提及Doug McIlroy的Unix哲學:小巧而鋒利的工具的結合解決更大的任務或者通常原創作者並沒有預想到的任務。

    我認為Go語言的Package體現了UNIX哲學精神。實際上每個package自身就是一個具有單一原則的變化單元的小型Go語言專案。

2) Open / Closed Principle - 開閉原則

 Bertrand Meyer曾經寫道:

Software entities should be open for extension, but closed for modification. –Bertrand Meyer, Object-Oriented Software Construction

該建議如何應用到現在的程式語言上:

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

}

typeA有一個year欄位以及Greet方法。 typeB嵌入了A做為欄位,從而,使B提供的Greet方法遮蔽了A的,呼叫時可以看到B的方法覆蓋了A

但是嵌入不僅僅是對於方法,它還能提供嵌入type的欄位訪問。如你所見,由於AB都在同一個package內,B可以訪問A的私有year欄位就像B已經宣告過。

因此 嵌入是一個強大的工具,它允許Go語言type對擴充套件是開放的。

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

}

在上邊這個例子中,typeCatLegs方法來計算它有幾條腿。我們嵌入Cat到一個新的typeOctoCat中,並宣告Octocats有五條腿。然而,儘管OctoCat定義了自己有五條腿,但是PrintLegs方法被呼叫時會返回4。

這是因為PrintLegs在typeCat中定義。它會將Cat做為它的接收者,因此它會使用CatLegs方法。Cat並不瞭解已嵌入的type,因此它的嵌入方法不能被修改。

由此,我們可以說Go語言的types對擴充套件開放,但是對修改是關閉的。

事實上,Go語言接收者的方法僅僅是帶有預先宣告形式的引數的function的語法糖而已:

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())
}

第一個function的接收者就是你傳進去的引數,而且由於Go語言不知道過載,所以說OctoCats並不能替換普通的Cats,這就引出了接下來一個原則:

3) Liskov Substitution Principle - 里氏替換原則

該原則由Barbara Liskov提出,大致上,它規定了兩種型別如果呼叫者不能區分出他們行為的不同,那麼他們是可替代的。

基於class的程式語言,里氏替換原則通常被解釋為一個抽象基類的各種具體子類的規範。但是Go語言沒有class或者inheritance(繼承),因此就不能以抽象類的層次結構實現替換。

  • Interfaces - 介面

    相反,Go語言的interface才有權替換。在Go語言中,type不需要宣告他們具體要實現的某個interface,相反的,任何想要實現interface的type僅需提供與interface宣告所匹配的方法。

    就Go語言而言,隱式的interface要比顯式的更令人滿意,這也深刻地影響著他們使用的方式。

    精心設計的interface更可能是小巧的,流行的做法是一個interface只包含一個方法。邏輯上來講小巧的interface使實現變得簡單,反之就很難做到。這就導致了由常見行為連線的簡單實現而組成的package。

  • io.Reader
    type Reader interface {
        // Read reads up to len(buf) bytes into buf.
        Read(buf []byte) (n int, err error)
    }

    我最喜愛的Go語言interface - io.Reader

    interfaceio.Reader非常簡單,Read讀取資料到提供的buffer,並返回撥用者讀取資料的bytes的數量以及讀取期間的任何錯誤。它看起來簡單但是很強大。

    因為io.Reader可以處理任何能轉換為bytes流的資料,我們可以在任何事情上構建readers:string常量、byte陣列、標準輸入、網路資料流、gzip後的tar檔案以及通過ssh遠端執行的命令的標準輸出。

    所有這些實現對於另外一個都是可替換的,因為他們都履行了相同的簡單合同。

    因此,里氏替換原則在Go語言的應用,可以用 Jim Weirich 的格言來總結:

    Require no more, promise no less. –Jim Weirich

接下來就到了"SOLID"第四個原則。

4) Interface Segregation Principle - 介面隔離原則

Clients should not be forced to depend on methods they do not use. –Robert C. Martin

在Go語言中,介面隔離原則的應用是指一個方法來完成其工作的孤立行為的過程。舉個“栗子”,編寫方法來儲存一個文件結構到磁碟的任務。

// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error

我可以這樣定義這個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方法使用一個interface來描述更為通用的型別。

隨著修改,任何實現了io.ReadWriteCloser介面的type都可以代替之前的*os.File。這使得 Save不僅擴充套件了它的應用範圍同時也給Save的呼叫者說明了type*os.File哪些方法是操作相關的。

做為Save的作者,我沒有了在*os.File上呼叫無關的方法選項了,因為他們都被隱藏於io.ReadWriteCloser介面。我們可以進一步地應用介面隔離原則。

首先,Save方法不太可能會保持單一功能原則,因為它要讀取的檔案內容應該是另外一段程式碼的責任。(譯註:待更新)因此我們可以縮小介面範圍,只傳入writingclosing

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

其次,通過向Save提供一種機制來關閉它的資料流,會導致另外一個問題:wc會在什麼情況下關閉。Save可能會無條件的呼叫Close或在成功的情況下呼叫Close

如果它想要在寫入document之後再寫入額外的資料時會引起Save的呼叫者一個問題。

type NopCloser struct {
        io.Writer
}

// Close has no effect on the underlying writer.
func (c *NopCloser) Close() error { return nil }

一個原始解決方案回事定義一個新的type,在其內嵌入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

5) Dependency Inversion Principle - 依賴反轉原則

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語言來講,依賴反轉意味著什麼呢:

如果你應用以上所有的原則,程式碼已經被分解成離散的有明確責任和目的的package,你的程式碼應該描述了它的依賴interface以及這些interface應該只描述他們需要的功能行為。換句話說就是他們不會再過多的改變。

因此,我認為Martin所講的在Go語言的應用是context,即你import graph(譯註:後文用“匯入圖”代替)的結構。

在Go語言中,你的匯入圖必須是非迴圈。不遵守此非迴圈的需求會導致編譯錯誤,但是更為嚴重的是它代表了一系列的設計錯誤。

所有條件都相同的情況下精心設計的匯入圖應該是廣泛的以及相對平坦的,而不是又高又窄。如果你有一個package的函式在沒有其他package的情況下就無法操作,也許這就表明了程式碼沒有考慮pakcage的邊界。

依賴反轉原則鼓勵你儘可能地像匯入圖一樣在mainpackage或者最高層級的處理程式內對具體細節負責,讓低層級程式碼來處理抽象的介面。

“SOLID” Go語言設計

回顧一下,當應用到Go語言設計中,每個“SOLID”原則都是強有力的宣告,但是加在一起他們有一箇中心主題。

  • 單一功能原則鼓勵你在package中構建functions、types以及方法表現出自然的凝聚力。types屬於彼此,functions為單一目的服務。
  • 開閉原則鼓勵你使用嵌入將簡單的type組合成更為複雜的。
  • 里氏替換原則鼓勵你在package之間表達依賴關係時用interface,而非具體型別。通過定義小巧的interface,我們可以更有信心地切實滿足其合約。
  • 介面隔離原則鼓勵你僅取決於所需行為來定義函式和方法。如果你的函式僅僅需要有一個方法的interface做為引數,那麼它很有可能只有一個責任。
  • 依賴反轉原則鼓勵你在編譯時將package所依賴的東西移除 - 在Go語言中我們可以看到這樣做使得執行時用到的某個特定的package的import宣告的數量減少。(譯註:待更新)

如果總結這個演講(譯註:該篇文章取自Dave大神在Golang UK Conference 2016的演講文字內容,文章結尾處有YouTube連結(需要翻牆))它可能會是:

interfaces let you apply the SOLID principles to Go programs

因為interface描繪了他們的pakcage的規定,而不是如何規定的。換個說法就是“解耦”,這確實是我們的目標,因為解耦的軟體修改起來更容易。

就像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

五十萬Go語言程式設計師會做什麼?顯然,他們會寫好多Go程式碼。實話實說,並不是所有的都是好的程式碼,一些可能會很糟糕。

...

Go語言程式設計師應當討論更多的是設計而非框架。我們應當不惜一切代價地關注重用而非效能。

我想要看到是今天的人們談論關於如何使用程式語言,無論是設計解決方案還是解決實際問題的選擇和侷限性。

我想要聽到的是人們談論如何通過精心設計、解耦、重用以及適應變化的方式來設計Go語言程式。

...還有一點

我們需要告訴世界優秀的軟體該如何編寫。告訴他們使用Go語言如何編寫優秀的、可組合的及易於變化的軟體。

...

感謝!

相關博文:

  1. Inspecting errors
  2. Should methods be declared on T or *T
  3. Ice cream makers and data races
  4. Stupid Go declaration tricks

原文視訊:Golang UK Conference 2016 - Dave Cheney - SOLID Go Design

原文連結:SOLID Go Design


相關文章