我最近寫了一個Go微服務應用程式,這個程式的設計來自三個靈感:

  • 清晰架構"Clean Architecture"¹ and SOLID (面向物件設計)² 設計 原則³

  • Spring的容器技術(Spring’s application context)⁴

  • Go的簡潔設計⁵ 特別是 Go的面向物件的設計⁶

我使用Spring的基於介面的程式設計和依賴注入(Dependency Injection)來實現Bob Martin的清晰架構(Clean Architecture),並遵循了Go的簡單程式設計風格。當它們之間存在衝突時,進行了取捨。我只採用了Clean Architecture的設計原則(主要是SOLID),因此實現的細節可能與其他SOLID實現不同。

我來自Java背景,對前兩個設計思想非常熟悉。在學習了Go之後,我逐漸認同了Go的簡單風格。粗略來說,有兩種不同的程式設計風格,一種是面向物件的, 它強調設計;另一種是非面向物件的,它信奉用最簡單的程式碼來實現使用者需要的功能,無需預先設計。 Go更接近第二陣營,儘管它有一些面向物件的功能。 Go的程式設計思路為我提供了一個重新評估面向物件程式設計的新視角,並影響了我的編碼風格。結果是我只在必要時才進行面向物件的設計,而我更傾向於使用更簡單的解決方案而不是完美的方案。

設計原則:
  1. 基於介面程式設計(Programming on interface)⁷

    本程式有三個主要業務層,用例(usecase),資料服務(dataservice)和域模型(model),其中只有域模型沒有介面,因為沒有必要。 當你訪問外部服務時,你可以通過介面進行訪問。

    // sqlUserDataServiceFactory is a empty receiver for Build method
    type sqlUserDataServiceFactory struct{}
    
    func (sudsf *sqlUserDataServiceFactory) Build(c container.Container, dataConfig *config.DataConfig)
        (dataservice.UserDataInterface, error) {
    
        dsc := dataConfig.DataStoreConfig
        dsi, err := datastorefactory.GetDataStoreFb(dsc.Code).Build(c, &dsc)
        if err != nil {
            return nil, errors.Wrap(err, "")
        }
        ds := dsi.(gdbc.SqlGdbc)
        uds := sqldb.UserDataSql{DB: ds}
        logger.Log.Debug("uds:", uds.DB)
        return &uds, nil
    
    }

    基於介面的程式設計的關鍵是將介面作為引數傳遞給函式,並返回介面而不是具體類 型。 例如,在上面的程式碼中,返回值-“dataservice.UserDataInterface”,它是一個介面,而不是struct。 呼叫函式不需要知道返回的具體結構,因為介面封裝了它需要的所有資訊。 這使你可以非常靈活地將返回的結構替換為另一個結構,而不會影響呼叫函式。

  2. 用工廠方法模式(factory method pattern)通過依賴注入(Dependency Injection)建立具體型別.

    程式容器負責建立具體型別並將其注入函式。 我將在 “依賴注入(Dependency Injection)”⁸中進行詳細解釋.

  3. 建立正確的依賴關係

    它意味著以下內容:
    • 程式中的各層或元件都有自己的單獨的包。 介面在頂級包中定義,具體型別隱藏在子包中。
    • 不同層之間僅依賴於介面而不依賴於具體型別
    • 從頂層向下的依賴層次是:“用例”,“資料服務”和“模型”。
          
      衡量依賴關係質量的一種方法是看匯入(import)語句的多少,匯入語句越少,依賴關係越好。
  4. 開閉原則(Open-close principle)⁹

    這是我最喜歡的設計原則。 它要求你在需要新增新功能時,不要修改現有程式碼,而是新增新程式碼。 實現它的方法是使用上面講到的#1和#2。 這個原則有許多很好的現實世界的例子,例如,資料訪問物件(DAO)¹⁰。 好處是你不會無意中搞亂現有程式碼,因為只新增新程式碼,這將大大減少測試工作量。

是否過度設計了?

與Java中的類似解決方案相比,由於Go的語言本身的簡單設計,本程式中的程式碼量要少很多,也非常簡潔。 但是對於來自其他程式語言(特別是動態語言如PHP,Ruby)的人來說,這個程式的設計可能有些重。 我也問了自己同樣的問題。 為了得到答案,需要比較成本和收益以得出最終結論。

通常來說有兩種型別的需求變更,業務邏輯變更和技術方案變更。 在編寫業務程式碼時,你不希望關注資料是來自MongoDB還是MySQL還是微服務。 在進行技術修改時,最大的噩夢是意外破壞業務邏輯。 一個好的設計將這兩種型別的編碼在程式中分開,讓你一次只關注一個。

一般來說,技術方案變更不會像業務邏輯變化那樣頻繁發生,但隨著微服務的普及,新技術將被更快地採用,這將加速技術變更。

設計帶來的好處:

以下是幾個示例,向你展示當需求變更時需要對程式進行的改動。 如果你看不太懂本節,可能需要先閱讀“程式設計¹¹,它將為你提供程式結構的描述。

從MySQL改成MongoDB:

首先,假設我們需要將域模型“User”的持久層從MySQL更改為MongoDB。以下是步驟:

  1. 在“appConfig [type] .yaml”檔案中新增MongoDB的新配置資訊

  2. 將“appConfig [type] .yaml”檔案中“useCaseConfig”部分下的“userConfig”值更改為指向MongoDB而不是MySql

  3. 在“appConfig.go”中為MongoDB建立一個新的結構型別

  4. 在“configValidator.go”中為MongoDB新增一個新常量並建立校驗規則。

  5. 在“datastorefactory”包中建立一個新的MongoDB工廠(MongoDB factory),並在“datstoreFactory.go”的“dbFactoryBuilderMap”中為MongoDB新增一個新條目。

  6. 在“userdata”下建立一個新資料夾“mongodb”,並新增MongoDB實現的程式碼。

通過當前的設計,大大減少了需求變化帶來的影響。整個程式碼修改沒有涉及業務邏輯程式碼。更改僅涵蓋資料服務層和應用程式容器,“用例”或“模型”層沒有任何更改。對於資料服務層(步驟6),我們只為MongoDB新增新程式碼,並且沒有更改任何現有的MySql程式碼。

通過步驟1到5,我們對容器(依賴注入)進行了更改以將MongoDB注入到應用程式中,這部分更改了現有程式碼,但只觸及了型別建立部分,其他一切程式碼都完好無損。

改變使用者註冊用例(registration use case)呼叫另一個RESTFul服務:

其次,假設隨著功能增多,應用程式變得越來越大,你決定將部分功能拆分為另一個微服務,例如支付服務。現在,你的程式碼需要呼叫另一個微服務,它是用RESTFul協議中實現的。以下是步驟:

  1. 在“appConfig [type] .yaml”檔案中為RESTFul配置新增新條目

  2. 將“useCaseConfig”部分下的“userConfig”值更改為指向RESTFul配置

  3. 在“appConfig.go”中為RESTFul使用者配置建立新的結構型別

  4. 在“configValidator.go”中為RESTFul新增一個新常量並建立校驗規則。

  5. 在“datastorefactory”子包中建立一個新的RESTFul工廠

  6. 將新的RESTFul資料介面新增到“RegistrationUseCase”結構中,並修改“registrationFactory.go”為其建立具體型別。

  7. 在“adaptor”下建立一個新資料夾,併為RESTFul支付服務建立程式碼。

通過步驟1到6,我們對容器(依賴注入)進行了更改,以將RESTFul注入到程式中,此部分將觸及現有程式碼。但是通過把更改限制在只對容器,它大大降低了修改的影響,並保護業務邏輯不會被意外更改。第7步是RESTFul服務的真正實現。

設計的成本:

接下來,讓我們評估設計的成本。

  1. 為用例(usecase)層建立介面

  2. 為資料服務層(dataservice)建立介面

  3. 建立呼叫其他微服務的介面

  4. 建立程式容器以執行依賴注入

步驟1到3幾乎沒有額外的工作,對於第3步,你可能無法繞過。

第4步有一定的工作量,並且比較複雜性。這是基於介面程式設計的結果。每個函式都通過介面呼叫另一個函式,但是你需要一個地方來建立具體的型別,那就是應用程式容器,其中所有的複雜性都在其中。大多數複雜性來自於我們希望簡化建立新型別帶來的工作,因此容器必須足夠靈活以適應新型別的加入。

如果你的程式不會引入很多新型別,或者你寧願將來花費更多時間但想現在節省一些時間,那麼你可以通過以下步驟使其更加簡單。首先,如果你不需要靈活地切換到另一個日誌記錄器,請刪除“logger”包。其次,刪除“config”包。這樣你不需從YAML檔案中讀取配置,但是你也失去了通過配置檔案更改應用程式行為的靈活性。第三,你甚至可以刪除工廠方法模式。但是,你還將失去上述所有優勢,並且可能會在進行技術更改時冒險破壞業務邏輯的風險。

配置管理:

某些修改的複雜性來自需要從檔案中讀取配置。 它是為了將來可以從配置伺服器(configuration server)(管理應用程式配置的程式)讀取配置做準備。 在微服務環境(特別是Docker或Kubernetes環境)中,伺服器URL是動態生成和銷燬的,無法在靜態檔案中進行管理。 我認為動態載入應用程式配置的功能是必須的而不是可有可無的。 使用當前的設計,我可以輕鬆地將“appConfig.go”更改為使用Viper¹²,它支援配置管理。

結論:

當前的設計為程式增加了一些複雜性,但在動態部署(docker或Kubernetes)環境中可能無法避免其中的一些。 總的來說,你可以從這些額外的工作中獲得很大的好處,所以我不認為這個設計是過度的。

源程式:

完整的源程式連結 github。

索引:

[1][The Clean Code Blog](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)

[2][S.O.L.I.D is for the first five object-oriented design (OOD) principles introduced by Robert C. Martin, popularly known as Uncle Bob and the acronym is introduced later by Michael Feathers](http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod)

[3][SOLID Go Design](https://dave.cheney.net/2016/08/20/solid-go-design)

[4][IoC Container ( Dependency Injection)](https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#beans)

[5][Go at Google: Language Design in the Service of Software Engineering](https://talks.golang.org/2012/splash.article)

[6][Is Go An Object Oriented Language?](https://spf13.com/post/is-go-object-oriented/)

[7][Interface-based programming](https://en.wikipedia.org/wiki/Interface-based_programming)

[8] Go Microservice with Clean architecture: Dependency Injection

[9][Open–closed principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle)

[10][Data access object](https://en.wikipedia.org/wiki/Data_access_object)

[11][Go Microservice with Clean Architecture: Application Design](https://blog.csdn.net/weixin_38748858/article/details/103708927)

[12][viper](https://github.com/spf13/vip