在清晰架構(Clean Architecture)中,應用程序的每一層(用例,數據服務和域模型)僅依賴於其他層的接口而不是具體類型。 在運行時,程序容器¹負責創建具體類型並將它們注入到每個函數中,它使用的技術稱為依賴注入²。 以下是要求。
容器包的依賴關系:
-
容器包是唯一依賴於具體類型和許多外部庫的包,因為它需要創建具體類型。 本程序中的所有其他軟件包主要僅依賴於接口。
-
外部庫可以包括DB和DB連接,gRPC連接,HTTP連接,SMTP服務器,MQ等。
-
#2中提到的具體類型的資源鏈接只需要創建一次並放入注冊表中,所有后來的請求都將從注冊表中檢索它們。
-
只有用例層需要訪問並依賴於容器包。
依賴注入的核心是工廠方法模式(factory method pattern)。
工廠方法模式(Factory Method Pattern):
實現工廠方法模式並不困難,這里³描述了是如何在Go中實現它的。困難的部分是使其可擴展,即如何避免在添加新工廠時修改代碼。
處理新工廠的方式有很多種,下面是常見的三種:
-
(1)使用if-else語句⁴
-
(2) 使用映射(map)保存不同的工廠⁵
-
(3) 使用反射生成新的具體類型。
#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
[4]Creating a factory method in Java that doesn’t rely on if-else
[6]Go Microservice with Clean Architecture: Transaction Support
[8]Uber’s dig