清晰架構(Clean Architecture)的 Go 微服務: 依賴注入(Dependency Injection)

在清晰架構(Clean Architecture)中,應用程序的每一層(用例,數據服務和域模型)僅依賴於其他層的接口而不是具體類型。 在運行時,程序容器 ¹ 負責創建具體類型並將它們注入到每個函數中,它使用的技術稱爲依賴注入 ²。 以下是要求。

容器包的依賴關係:

  1. 容器包是唯一依賴於具體類型和許多外部庫的包,因爲它需要創建具體類型。 本程序中的所有其他軟件包主要僅依賴於接口。

  2. 外部庫可以包括 DB 和 DB 連接,gRPC 連接,HTTP 連接,SMTP 服務器,MQ 等。

  3. #2 中提到的具體類型的資源鏈接只需要創建一次並放入註冊表中,所有後來的請求都將從註冊表中檢索它們。

  4. 只有用例層需要訪問並依賴於容器包。

依賴注入的核心是工廠方法模式 (factory method pattern)。

工廠方法模式(Factory Method Pattern):

實現工廠方法模式並不困難,這裏 ³ 描述了是如何在 Go 中實現它的。困難的部分是使其可擴展,即如何避免在添加新工廠時修改代碼。

處理新工廠的方式有很多種,下面是常見的三種:

#1 不是一個好選擇,因爲你需要在添加新類型時修改現有代碼。 #3 是最好的,因爲添加新工廠時現有代碼不需更改。在 Java 中,我會使用#3,因爲 Java 具有非常優雅的反射實現。你可以執行類似 “(Animal)Class.forName(”className“)。newInstance()” 的操作,即你可以將類的名稱作爲函數中的字符串參數傳遞進來,並通過反射從中創建一個類型的新實例,然後將結構轉換爲適當的類型(可能是它的一個超級類型(super type),這是非常強大的。由於 Go 的反射不如 Java,#3 不是一個好選擇。在 Go 中,由反射創建的實例是反射類型而不是實際類型,並且你無法在反射類型和實際類型之間轉換類型,它們處於兩個不同的世界中,這使得 Go 中的反射難以使用。所以我選擇#2,它比#1 好,但是在添加新類型時需要更改少部分代碼。

以下是數據存儲工廠的代碼。它有一個 “dsFbInterface”,其中有一個“Build” 函數需要由每個數據存儲工廠實現。 “Build”是工廠的關鍵部分。 “dsFbMap”是每個數據庫(或 gRPC)的代碼(code)與實際工廠之間的映射。這是添加數據庫時需要更改的部分。

// To map "database code" to "database interface builder"
// Concreate builder is in corresponding factory file. For example, "sqlFactory" is in "sqlFactory".go
var dsFbMap = map[string]dsFbInterface{
	config.SQLDB:      &sqlFactory{},
	config.COUCHDB:    &couchdbFactory{},
	config.CACHE_GRPC: &cacheGrpcFactory{},
}

// DataStoreInterface serve as a marker to indicate the return type for Build method
type DataStoreInterface interface{}

// The builder interface for factory method pattern
// Every factory needs to implement Build method
type dsFbInterface interface {
	Build(container.Container, *config.DataStoreConfig) (DataStoreInterface, error)
}

//GetDataStoreFb is accessors for factoryBuilderMap
func GetDataStoreFb(key string) dsFbInterface {
	return dsFbMap[key]
}

以下是 “sqlFactory” 的程序,它實現了上面的代碼中定義的 “dsFbInterface”。 它爲 MySql 數據庫創建數據存儲。 在“Build” 函數中,它首先從註冊表中檢索數據存儲(MySql),如果找到,則返回,否則創建一個新的並將其放入註冊表。
因爲註冊表可以存儲任何類型的數據,所以我們需要在檢索後將返回值轉換爲適當的類型(*sql.DB)。 “databasehandler.SqlDBTx”是實現 “SqlGdbc” 接口的具體類型。 它的創建是爲了支持事務管理。 代碼中調用 “sql.Open()” 來打開數據庫連接,但它並沒有真正執行任何連接數據庫的操作。 因此,需調用 “db.Ping()” 去訪問數據庫以確保數據庫正在運行。

// sqlFactory is receiver for Build method
type sqlFactory struct{}

// implement Build method for SQL database
func (sf *sqlFactory) Build(c container.Container, dsc *config.DataStoreConfig) (DataStoreInterface, error) {
	key := dsc.Code
	//if it is already in container, return
	if value, found := c.Get(key); found {
		sdb := value.(*sql.DB)
		sdt := databasehandler.SqlDBTx{DB: sdb}
		logger.Log.Debug("found db in container for key:", key)
		return &sdt, nil
	}

	db, err := sql.Open(dsc.DriverName, dsc.UrlAddress)
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	// check the connection
	err = db.Ping()
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	dt := databasehandler.SqlDBTx{DB: db}
	c.Put(key, db)
	return &dt, nil

}

數據服務工廠(Data service factory)

數據服務層使用工廠方法模式來創建數據服務類型。 可以有不同的策略來應用此模式。 在構建數據服務工廠時,我使用了三種不同的策略,每種策略都有其優缺點。 我將詳細解釋它們,以便你可以決定在那種情況下使用哪一個。

基礎工廠(Basic factory)

最簡單的是 “cacheGrpcFactory”,因爲數據存儲只有一個底層實現(即 gRPC),所以只創建一個工廠就行了。

二級工廠(Second level factory)

對於數據庫工廠,情況並非如此。 因爲我們需要每個數據服務同時支持多個數據庫,所以需要二級工廠,這意味着對於每種數據服務類型,例如 “UserDataService”,我們需要爲每個支持的數據庫使用單獨的工廠。 現在,由於有兩個數據庫,我們需要兩個工廠。

你可以從上面的圖像中看到,我們需要四個文件來完成 “UserDataService”,其中“userDataServiceFactoryWrapper.go” 是在 “userdataservicefactory” 文件夾中調用實際工廠的封裝器(wrapper)。 “couchdbUserDataServiceFactory.go”和 “sqlUserDataServiceFactory.go” 是 CouchDB 和 MySql 數據庫的真正工廠。 “userDataServiceFactory.go”定義了接口。 如果你有許多數據服務,那麼你將創建許多類似代碼。

簡化工廠(Simplified factory)

有沒有辦法簡化它? 有的,這是第三種方式,但也帶來一些問題。 以下是 “courseDataServiceFactory.go” 的代碼。 你可以看到只需一個文件而不是之前的四個文件。 代碼類似於我們剛纔談到的“userDataServiceFactory”。那麼它是如何如何簡化代碼的呢?

關鍵是爲底層數據庫鏈接創建統一的接口。 在 “courseDataServiceFactory.go” 中,可以在調用 “dataStoreFactory” 之後獲得底層數據庫鏈接統一接口,並將 “CourseDataServiceInterface” 的 DB 設置爲正確的 “gdbc”(只要它實現“gdbc” 接口,它可以是任何數據庫鏈接)。

var courseDataServiceMap = map[string]dataservice.CourseDataInterface{
	config.COUCHDB: &couchdb.CourseDataCouchdb{},
	config.SQLDB:   &sqldb.CourseDataSql{},
}

// courseDataServiceFactory is an empty receiver for Build method
type courseDataServiceFactory struct{}

// GetCourseDataServiceInterface is an accessor for factoryBuilderMap
func GetCourseDataServiceInterface(key string) dataservice.CourseDataInterface {
	return courseDataServiceMap[key]
}

func (tdsf *courseDataServiceFactory) Build(c container.Container, dataConfig *config.DataConfig) (DataServiceInterface, error) {
	dsc := dataConfig.DataStoreConfig
	dsi, err := datastorefactory.GetDataStoreFb(dsc.Code).Build(c, &dsc)
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	gdbc := dsi.(gdbc.Gdbc)
	gdi := GetCourseDataServiceInterface(dsc.Code)
	gdi.SetDB(gdbc)
	return gdi, nil
}

它的缺點是,對於任何支持的數據庫,需要實現以下代碼中 “SqlGdbc” 和“NoSqlGdbc”接口,即使它只使用其中一個,另一個只是空實現(以滿足接口要求)並沒有被使用。 如果你只有少數幾個數據庫需要支持,這可能是一個可行的解決方案,否則它將變得越來越難以管理。

// SqlGdbc (SQL Go database connection) is a wrapper for SQL database handler 
type SqlGdbc interface {
	Exec(query string, args ...interface{}) (sql.Result, error)
	Prepare(query string) (*sql.Stmt, error)
	Query(query string, args ...interface{}) (*sql.Rows, error)
	QueryRow(query string, args ...interface{}) *sql.Row
	// If need transaction support, add this interface
	Transactioner
}

// NoSqlGdbc (NoSQL Go database connection) is a wrapper for NoSql database handler.
type NoSqlGdbc interface {
	// The method name of underline database was Query(), but since it conflicts with the name with Query() in SqlGdbc,
	// so have to change to a different name
	QueryNoSql(ctx context.Context, ddoc string, view string) (*kivik.Rows, error)
	Put(ctx context.Context, docID string, doc interface{}, options ...kivik.Options) (rev string, err error)
	Get(ctx context.Context, docID string, options ...kivik.Options) (*kivik.Row, error)
	Find(ctx context.Context, query interface{}) (*kivik.Rows, error)
	AllDocs(ctx context.Context, options ...kivik.Options) (*kivik.Rows, error)
}

// gdbc is an unified way to handle database connections. 
type Gdbc interface {
	SqlGdbc
	NoSqlGdbc
}

除了上面談到的那個之外,還有另一個副作用。 在下面的代碼中,“CourseDataInterface”中的 “SetDB” 函數打破了依賴關係。 因爲 “CourseDataInterface” 是數據服務層接口,所以它不應該依賴於 “gdbc” 接口,這是下面一層的接口。 這是本程序的依賴關係中的第二個缺陷,第一個是在事物管理⁶模塊。 目前對它沒有好的解決方法,如果你不喜歡它,就不要使用它。 可以創建類似於 “userFataServiceFactory” 的二級工廠,只是程序較長而已。

import (
	"github.com/jfeng45/servicetmpl/model"
	"github.com/jfeng45/servicetmpl/tool/gdbc"
)

// CourseDataInterface represents interface for persistence service for course data
// It is created for POC of courseDataServiceFactory, no real use.
type CourseDataInterface interface {
	FindAll() ([]model.Course, error)
	SetDB(gdbc gdbc.Gdbc)
}

怎樣選擇?

怎樣選擇是用簡化工廠還是二級工廠?這取決於變化的方向。如果你需要支持大量新數據庫,但新的數據服務不多(由新的域模型類型決定),那麼選二級工廠,因爲大多數更改都會發生在數據存儲工廠中。但是如果支持的數據庫不會發生太大變化,並且數據服務的數量可能會增加很多,那麼選擇簡化工廠。如果兩者都可能增加很多呢?那麼只能使用二級工廠,只是程序會比較長。

怎樣選擇使用基本工廠還是二級工廠?實際上,即使你需要支持多個數據庫,但不需同時支持多個數據庫,你仍然可以使用基本工廠。例如,你需要從 MySQL 切換到 MongoDB,即使有兩個不同的數據庫,但在切換後,你只使用 MongoDB,那麼你仍然可以使用基本工廠。對於基本工廠,當有多種類型時,你需要更改代碼以進行切換(但對於二級工廠,你只需更改配置文件),因此如果你不經常更改代碼,這是可以忍受的。

備註:上面是我在寫這段代碼時的想法。但如果現在讓我選擇,我可能不會使用簡化工廠。因爲我對程序複雜度有了不同的認識。我依據的原則並沒有變,都是要降低代碼複雜度。但我以前認爲代碼越長越複雜,但現在我會加上另外一個維度,就是代碼的結構複雜度。二級工廠雖然代碼長了很多,但結構簡單,只要完成了一個,就可以拷貝出許多,結構幾乎一模一樣,這樣不論讀寫都非常容易。它的複雜度是線性增加的,而且不會有其他副作用。另外,你可以使用代碼生成器等工具來自動生成,以提高效率。而 “簡化工廠” 雖然代碼量少了,但結構複雜,它的複雜度增加很快,而且副作用太大,很難管理。

依賴注入(Dependency Injection)庫

Go 中已經有幾個依賴注入庫,爲什麼我不使用它們?我有意在項目初期時不使用任何庫,所以我可以更好地控制程序結構,只有在完成整個程序結構佈局之後,我纔會考慮用外部庫替換本程序的某些組件。

我簡要地看了幾個流行的依賴注入庫,一個是來自優步⁷的 Dig⁸,另一個是來自谷歌 ¹⁰的 Wire⁹ 。 Dig 使用反射,Wire 使用代碼生成。這兩種方法我都不喜歡,但由於 Go 目前不支持泛型,因此這些是唯一可用的選項。雖然我不喜歡他們的方法,但我不得不承認這兩個庫的依賴注入功能更全。

我試了一下 Dig,發現它沒有使代碼更簡單,所以我決定繼續使用當前的解決方案。在 Dig 中,你爲每個具體類型創建 “(build)” 函數,然後將其註冊到容器,最後容器將它們自動連接在一起以創建頂級類型。本程序的複雜性是因爲我們需要支持兩個數據庫實現,因此每個域模型有兩個不同的數據庫鏈接和兩組不同的數據服務實現。在 Dig 中沒有辦法使這部分更簡單,你仍然需要創建所有工廠然後把它們註冊到容器。當然,你可以使用 “if-else” 方法來實現工廠,這將使代碼更簡單,但你以後需要付出更多努力來維護代碼。

我的方法簡單易用,並且還支持從文件加載配置,但是你需要了解它的原理以擴展它。 Dig 提供的附加功能是自動加載依賴關係。如果你的應用程序有很多類型並且類型之間有很多複雜的依賴關係,那麼你可能需要切換到 Dig 或 Wire,否則請繼續使用當前的解決方案

接口設計

下面是 “userDataServiceFactoryWrapper” 的代碼.

// DataServiceInterface serves as a marker to indicate the return type for Build method
type DataServiceInterface interface{}

// userDataServiceFactory is a empty receiver for Build method
type userDataServiceFactoryWrapper struct{}

func (udsfw *userDataServiceFactoryWrapper) Build(c container.Container, dataConfig *config.DataConfig) 
    (DataServiceInterface, error) {
	key := dataConfig.DataStoreConfig.Code
	udsi, err := userdataservicefactory.GetUserDataServiceFb(key).Build(c, dataConfig)
	if err != nil {
		return nil, errors.Wrap(err, "")
	}
	return udsi, nil
}

你可能注意到了 “Build()” 函數的返回類型是 “DataServiceInterface”,這是一個空接口,爲什麼我們需要一個空接口? 我們可以用“interface {}” 替換 “DataServiceInterface” 嗎?

// userDataServiceFactory is a empty receiver for Build method
type userDataServiceFactoryWrapper struct{}

func (udsfw *userDataServiceFactoryWrapper) Build(c container.Container, dataConfig *config.DataConfig) 
    (interface{}, error) {
...
}

如果將返回類型從 “DataServiceInterface” 替換爲 “interface {}”,結果是相同的。 “DataServiceInterface” 的好處是它可以告訴我函數的返回類型,即數據服務接口; 實際上,真正的返回類型是 “dataservice.UserDataInterface”,但是“DataStoreInterface” 現在已經足夠好了,一個小訣竅讓生活變得輕鬆一點。

結論:

程序容器使用依賴注入創建具體類型並將它們注入每個函數。 它的核心是工廠方法模式。 在 Go 中有三種方法可以實現它,最好的方法是在映射(map)中保存不同的工廠。 將工廠方法模式應用於數據服務層也有不同的方法,它們各自都有利有弊。 你需要根據應用程序的更改方向選擇正確的方法。

源程序:

完整的源程序鏈接 github: https://github.com/jfeng45/servicetmpl

索引:

[1]Go Microservice with Clean Architecture: Application Container

[2] Inversion of Control Containers and the Dependency Injection pattern

[3]]Golang Factory Method

[4]Creating a factory method in Java that doesn’t rely on if-else

[5]Tom Hawtin’s answer

[6]Go Microservice with Clean Architecture: Transaction Support

[7]Dependency Injection in Go

[8]Uber’s dig

[9]Go Dependency Injection with Wire

[10][Google’s Wire: Automated Initialization in Go](https://github.com/google/wire)

不堆砌術語,不羅列架構,不迷信權威,不盲從流行,堅持獨立思考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://blog.csdn.net/weixin_38748858/article/details/103999523