Go 語言如何解決程式碼耦合

樓蘭發表於2019-02-06

什麼是耦合?

在軟體中,衡量物件、包、函式任何兩個部分相互依賴的程度叫做耦合。 例如下面的程式碼:

type Config struct {
    DSN            string
    MaxConnections int
    Timeout        time.Duration
}

type PersonLoader struct {
    Config *Config
}
複製程式碼

缺少任何一方就無法存在這兩個物件,編譯更會報錯。因此,它們被認為是緊密耦合的。

為什麼緊密耦合的程式碼有問題?

緊密耦合的程式碼有許多不利的影響,但最重要的是它可能會引起程式碼散彈式的修改。散彈式的修改(Shotgun Surgery)是指一部分的程式碼變化,導致在程式碼的其他地方需要根據變化情況,進行相應的修改。 請考慮以下程式碼:

func GetUserEndpoint(resp http.ResponseWriter, req *http.Request) {
    // get and check inputs
    ID, err := getRequestedID(req)
    if err != nil {
        resp.WriteHeader(http.StatusBadRequest)
        return
    }

    // load requested data
    user, err := loadUser(ID)
    if err != nil {
        // technical error
        resp.WriteHeader(http.StatusInternalServerError)
        return
    }
    if user == nil {
        // user not found
        resp.WriteHeader(http.StatusNoContent)
        return
    }
    
    // prepare output
    switch req.Header.Get("Accept") {
    case "text/csv":
        outputAsCSV(resp, user)

    case "application/xml":
        outputAsXML(resp, user)

    case "application/json":
        fallthrough

    default:
        outputAsJSON(resp, user)
    }
}
複製程式碼

現在考慮如果我們要向 User 物件新增密碼欄位會發生什麼。假設我們不希望該欄位作為API 響應的一部分輸出。然後,我們必須在 outputAsCSV(), outputAsXML() 和outputAsJSON() 函式中引入其他程式碼。
這一切似乎合理的,但是如果我們還有另一個入口也包含 User 型別作為其輸出的一部分,如“Get All Users”入口,會發生什麼?這會使我們也必須在那裡做出類似的改變。這是因為“Get All Users”入口與使用者型別的輸出呈現緊密耦合。
另一方面,如果我們將渲染邏輯從 GetUserHandler() 移動到 User 型別,那麼我們只有一個地方可以進行更改。也許更重要的是,這個地方很明顯且很容易找到,因為它位於我們新增新欄位的位置旁邊,從而提高了整個程式碼的可維護性。

設計模式原則-依賴倒轉(Dependency Inversion Principle,DIP)

依賴倒置原則是 Robert C. Martin 在 1996 年發表的題為“依賴性倒置原則”的 C ++ 報告的文章中創造的術語。他將其定義為:高階模組不應該依賴低階模組。兩者都應該取決於抽象。抽象不應該依賴於細節。細節應取決於抽象。
Robert C. Martin 寥寥數語卻極具智慧,以下是我將其轉化為 Go 語言對應結論:
1)高層次包不應該依賴於低層次包。當我們編寫一個 Go 語言應用程式時,從 main() 呼叫一些包,這些可以被認為是高階包。相反一些包與外部資源互動的包,如資料庫,通常不是從 main() 呼叫,而是從業務邏輯層呼叫,而業務邏輯層會低1-2級。 關於這一點,高層次的包不應該依賴於低階別的包。高階包依賴於抽象,而不是依賴於這些基本細節實現的包。從而保持它們分離。
2)結構體不應該依賴於結構體。當一個結構體使用另一個結構體作為方法輸入或成員變數時:

type PizzaMaker struct{}

func (p *PizzaMaker) MakePizza(oven *SuperPizaOven5000) {
    pizza := p.buildPizza()
    oven.Bake(pizza)
}
複製程式碼

將這兩個結構分離是不可能的,這些物件是緊密耦合的,因此不是很靈活。考慮這個真實的例子:假設我走進旅行社問,我可以訂澳洲航空公司星期四下午三點半飛往悉尼的 15D 座位嗎?旅行社將很難滿足我的要求。 但是如果我放寬要求,改為詢問我可以訂一張週四飛往悉尼的機票嗎?這樣旅行社的生活就更靈活了,我也更有可能得到我的座位。更新我們的程式碼如下:

type PizzaMaker struct{}

func (p *PizzaMaker) MakePizza(oven Oven) {
    pizza := p.buildPizza()
    oven.Bake(pizza)
}

type Oven interface {
    Bake(pizza Pizza)
}
複製程式碼

現在我們可以使用任何實現 Bake() 方法的物件。
3)介面不應該依賴於結構體。與前一點類似,這是關於需求的特殊性。如果我們定義我們的介面為:

type Config struct {
    DSN            string
    MaxConnections int
    Timeout        time.Duration
}

type PersonLoader interface {
    Load(cfg *Config, ID int) *Person
}
複製程式碼

然後我們將 PersonLoader 與指定的 Config 結構體解耦。

type PersonLoaderConfig interface {
    DSN() string
    MaxConnections() int
    Timeout() time.Duration
}

type PersonLoader interface {
    Load(cfg PersonLoaderConfig, ID int) *Person
}
複製程式碼

現在,我們可以重用 PersonLoader 而無需任何更改。
(上面的結構應該被認為是指提供邏輯和/或實現介面的結構,並且不包括用作資料傳輸物件的結構)

修復緊密耦合的程式碼

拋棄所有的背景,讓我們用更為豐富的例子深入探討如何解決緊密耦合程式碼。 我們的示例從兩個不同包中的兩個物件,Person 和 BlueShoes 開始,如下:

Go 語言如何解決程式碼耦合
如你所見,它們是緊密耦合的; 如果沒有 BlueShoes,Person 結構就無法存在。 如果你像原文作者一樣,有 Java/C++ 或者其他程式碼的經驗,那麼你將物件解耦的第一直覺就是在 Shoes Package 中定義一個介面。 結果會如下:
Go 語言如何解決程式碼耦合
在許多語言中,這將是它的最終結果。但是對於 Go 語言,我們可以進一步解耦這些物件。
在此之前,我們還應該注意另一個問題。 您可能已經注意到,Person struct 只實現了一個 Walk() 方法,而 Footwear 同時實現了 Walk() 和 Run() 兩個方法。這種差異使得 Person 和 Footwear 之間的關係有些不清楚,並且違反了 Robert C. Martin 提出的另一個名為 Interface Segregation Principle(ISP) 的介面隔離原則,該原則指出:Clients should not be forced to depend on methods they do not use. 幸運的是,我們可以通過在 People Package 中定義介面來解決這兩個問題,而不是像上圖中在 Shoes Package 中定義介面:
Go 語言如何解決程式碼耦合
這件小事也許不值得你珍惜寶貴的時間,但差異很大。 在這個例子中,我們兩個 Package 現在完全解耦了。People 不需要依賴或使用 Shoes Package。
通過這樣更改使得 People Package 介面需求清晰,簡潔且易於查詢,因為它們位於示例包中,最後,對 Shoes Package 的更改不太可能影響 People Package。

總結

正如原文作者 Go 語言依賴注入實踐 一書中所寫,Unix 哲學是 Go 語言中最受歡迎的概念之一,其中指出:“Write programs that do one thing and do it well. Write programs to work together.”
意思是把不同需求進行區分,讓你的每份程式碼只做一件事情並且做好,使彼此之間相互配合工作。
這些概念在 Go 標準庫中無處不在,甚至出現在語言的設計決策中。像隱式實現介面(即沒有“implements”關鍵字)。這樣的決策使我們(該語言的使用者)能夠實現解耦程式碼,這些程式碼可以用於單一目的並且易於編寫。
輕耦合程式碼使理解更為容易,因為你所需要的所有資訊都集中在一個地方,這會讓測試和擴充套件變得非常輕鬆。
所以當你下次看到一個具體的物件作為函式引數或成員變數時,問問你自己這是必要的嗎?如果我將其更改為介面,會更靈活、更易於理解或更易於維護嗎?

govip cn 每日新聞推薦的文章 how-to-fix-tightly-coupled-go-code

相關文章