清晰架構(Clean Architecture)的一個理念是隔離程序的框架,使框架不會接管你的應用程序,而是由你決定何時何地使用它們。在本程序中,我特意不在開始時使用任何框架,因此我可以更好地控制程序結構。只有在整個程序結構布局完成之后,我才會考慮用某些庫替換本程序的某些組件。這樣,引入的框架或第三方庫的影響就會被正確的依賴關系所隔離。目前,除了logger,數據庫,gRPC和Protobuf(這是無法避免的)之外,我只使用了兩個第三方庫ozzo-validation¹和YAML²,而其他所有庫都是Go的標准庫。
你可以使用本程序作為構建應用程序的基礎。你可能會問,那么本框架豈不是要接管整個應用程序嗎?是的。但事實是,無論是你自建框架還是引進第三方框架,你都需要一個基本框架作為構建應用程序的基礎。該基礎需要具有正確的依賴性和可靠的設計,然后你可以決定是否引入其他庫。你當然可以自己建立一個框架,但你最終可能會花費大量的時間和精力來完善它。你也可以使用本程序作為起點,而不是構建自己的項目,從而為你節省時間和精力。
程序容器是項目中最復雜的部分,是將應用程序的不同部分粘合在一起的關鍵組件。本程序的其他部分是直截了當且易於理解的,但這一部分不是。好消息是,一旦你理解了這一部分,那么整個程序就都在掌控之中。
容器包(“container” package)的組成部分:
容器包由五部分組成:
-
“容器”(“container”)包:它負責創建具體類型並將它們注入其他文件。 頂級包中只有一個文件“container.go”,它定義容器的接口。
-
“servicecontainer”子包:容器接口的實現。 只有一個文件“serviceContainer.go”,這是“容器”包的關鍵。 以下是代碼。 它的起點是“InitApp”,它從文件中讀取配置數據並設置日志記錄器(logger)。
type ServiceContainer struct {
FactoryMap map[string]interface{}
AppConfig *config.AppConfig
}
func (sc *ServiceContainer) InitApp(filename string) error {
var err error
config, err := loadConfig(filename)
if err != nil {
return errors.Wrap(err, "loadConfig")
}
sc.AppConfig = config
err = loadLogger(config.Log)
if err != nil {
return errors.Wrap(err, "loadLogger")
}
return nil
}
// loads the logger
func loadLogger(lc config.LogConfig) error {
loggerType := lc.Code
err := logFactory.GetLogFactoryBuilder(loggerType).Build(&lc)
if err != nil {
return errors.Wrap(err, "")
}
return nil
}
// loads the application configurations
func loadConfig(filename string) (*config.AppConfig, error) {
ac, err := config.ReadConfig(filename)
if err != nil {
return nil, errors.Wrap(err, "read container")
}
return ac, nil
}
-
“configs”子包:負責從YAML文件加載程序配置,並將它們保存到“appConfig”結構中以供容器使用。
-
“logger”子包:它里面只有一個文件“logger.go”,它提供了日志記錄器接口和一個“Log”變量來訪問日志記錄器。 因為每個文件都需要依賴記錄,所以它需要一個獨立的包來避免循環依賴。
-
最后一部分是不同類型的工廠(factory)。
它的內部分層與應用層分層相匹配。 對於“usecase”和“dataservice”層,有“usecasefactory”和“dataservicefactory”。 另一個工廠是“datastorefactory”,它負責創建底層數據處理鏈接。 因為數據提供者可以是gRPC或除數據庫之外的其他類型的服務,所以它被稱為“datastorefactry”而不是“databasefactory”。 日志記錄組件(logger)也有自己的工廠。
用例工廠(Use Case Factory):
對於每個用例,例如“registration”,接口在“usecase”包中定義,但具體類型在“usecase”包下的“registration”子包中定義。 此外,容器包中有一個對應的工廠負責創建具體的用例實例。 對於“注冊(registration)”用例,它是“registrationFactory.go”。 用例與用例工廠之間的關系是一對一的。 用例工廠負責創建此用例的具體類型(concrete type)並調用其他工廠來創建具體類型所需的成員(member in a struct)。 最低級別的具體類型是sql.DBs和gRPC連接,它們需要被傳遞給持久層,這樣才能訪問數據庫中的數據。
如果Go支持泛型,你可以創建一個通用工廠來構建不同類型的實例。 現在,我必須為每一層創建一個工廠。 另一個選擇是使用反射(refection),但它有不少問題,因此我沒有采用。
“Registration” 用例工廠(Use Case Factory):
每次調用工廠時,它都會構建一個新類型。以下是“注冊(Registration)”用例創建具體類型的代碼。 它是工廠方法模式(factory method pattern)的典型實現。 如果你想了解有關如何在Go中實現工廠方法模式的更多信息,請參閱此處³.
// Build creates concrete type for RegistrationUseCaseInterface
func (rf *RegistrationFactory) Build(c container.Container, appConfig *config.AppConfig, key string) (UseCaseInterface, error) {
uc := appConfig.UseCase.Registration
udi, err := buildUserData(c, &uc.UserDataConfig)
if err != nil {
return nil, errors.Wrap(err, "")
}
tdi, err := buildTxData(c, &uc.TxDataConfig)
if err != nil {
return nil, errors.Wrap(err, "")
}
ruc := registration.RegistrationUseCase{UserDataInterface: udi, TxDataInterface: tdi}
return &ruc, nil
}
func buildUserData(c container.Container, dc *config.DataConfig) (dataservice.UserDataInterface, error) {
dsi, err := dataservicefactory.GetDataServiceFb(dc.Code).Build(c, dc)
if err != nil {
return nil, errors.Wrap(err, "")
}
udi := dsi.(dataservice.UserDataInterface)
return udi, nil
}
數據存儲工廠(Data store factory):
“注冊(Registration)”用例需要通過數據存儲工廠創建的數據庫鏈接來訪問數據庫。 所有代碼都在“datastorefactory”子包中。 我詳細解釋了數據存儲工廠如何工作,請看這篇文章依賴注入(Dependency Injection)。
數據存儲工廠的當前實現支持兩個數據庫和一個微服務,MySql和CouchDB,以及gRPC緩存服務; 每個實現都有自己的工廠文件。 如果引入了新數據庫,你只需添加一個新的工廠文件,並在以下代碼中的“dsFbMap”中添加一個條目。
// 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]
}
以下是MySql數據庫工廠的代碼,它實現了上面的代碼中定義的“dsFbInterface”。 它創建了MySql數據庫鏈接。
容器內部有一個注冊表(registry),用作數據存儲工廠創建的鏈接(如DB或gRPC連接)的緩存,它們在整個應用程序創建一次。 無論何時需要它們,需首先從注冊表中檢索它,如果沒有找到,則創建一個新的並將其放入注冊表中。 以下是“Build”代碼。
// 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
}
Grpc Factory:
對於“listUser”用例,它需要調用gRPC微服務(緩存服務),而創建它的工廠是“cacheFactory.go”。 目前,數據服務的所有鏈接都是由數據存儲工廠創建的。 以下是gRPC工廠的代碼。 “Build”方法與“SqlFactory”的非常相似。
// DataStoreInterface serve as a marker to indicate the return type for Build method
type DataStoreInterface interface{}
// cacheGrpcFactory is an empty receiver for Build method
type cacheGrpcFactory struct{}
func (cgf *cacheGrpcFactory) 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 {
return value.(*grpc.ClientConn), nil
}
//not in map, need to create one
logger.Log.Debug("doesn't find cacheGrpc key=%v need to created a new one\n", key)
conn, err := grpc.Dial(dsc.UrlAddress, grpc.WithInsecure())
if err != nil {
return nil, errors.Wrap(err, "")
}
c.Put(key, conn)
return conn, err
}
Logger factory:
Logger有自己的子包名為“loggerfactory”,其結構與“datastorefactory”子包非常相似。 “logFactory.go”定義了日志記錄器工廠構建器接口(builder interface)和映射(map)。 每個單獨的日志記錄器都有自己的工廠文件。 以下是日志工廠的代碼:
// logger mapp to map logger code to logger builder
var logfactoryBuilderMap = map[string]logFbInterface{
config.ZAP: &ZapFactory{},
config.LOGRUS: &LogrusFactory{},
}
// interface for logger factory
type logFbInterface interface {
Build(*config.LogConfig) error
}
// accessors for factoryBuilderMap
func GetLogFactoryBuilder(key string) logFbInterface {
return logfactoryBuilderMap[key]
}
以下是ZAP工廠的代碼。 它類似於數據存儲工廠。 只有一個區別。 由於記錄器創建功能僅被調用一次,因此不需要注冊表。
// receiver for zap factory
type ZapFactory struct{}
// build zap logger
func (mf *ZapFactory) Build(lc *config.LogConfig) error {
err := zap.RegisterLog(*lc)
if err != nil {
return errors.Wrap(err, "")
}
return nil
}
配置文件:
配置文件使你可以全面了解程序的整體結構:
上圖顯示了文件的前半部分。 第一部分是它支持的數據庫配置; 第二部分是帶有gRPC的微服務; 第三部分是它支持的日志記錄器; 第四部分是本程序在運行時使用的日志記錄器
下圖顯示了文件的后半部分。 它列出了應用程序的所有用例以及每個用例所需的數據服務。
配置文件中應保存哪些數據?
不同的組件具有不同的配置項,一些組件可能具有許多配置項,例如日志記錄器。 我們不需要在配置文件中保存所有配置項,這可能使其太大而無法管理。 通常我們只需要保存需要在運行時更改的選項或者可以在不同環境中(dev, prod, qa)值不同的選項。
設計是如何進化的?
容器包里似乎有太多東西,問題是我們是否需要所有這些?如果你不需要所有功能,我們當然可以簡化它。當我開始創建它時,它非常簡單,我不斷添加功能,最終它才越來越復雜。
最開始時,我只是想使用工廠方法模式來創建具體類型,沒有日志記錄,沒有配置文件,沒有注冊表。
我從用例和數據存儲工廠開始。最初,對於每個用例,都會創建一個新的數據庫鏈接,這並不理想。因此,我添加了一個注冊表來緩存所有連接,以確保它們只創建一次。
然后我發現(我從這里獲得了一些靈感⁵)將所有配置信息放在一個文件中進行集中管理是個好主意,這樣我就可以在不改變代碼的情況下進行更改。
我創建了一個YAML文件(appConfig [type] .yaml)和“appConfig.go”來將文件中的內容加載到應用程序配置結構(struct) - “appConfig”中並將其傳遞給工廠構建器(factory builder)。 “[type]”可以是“prod”,“dev”,“test”等。配置文件只加載一次。目前,它沒有使用任何第三方庫,但我想將來切換到Vipe⁶,因為它可以支持從配置服務器中動態重新加載程序配置。要切換到Vipe,我只需要更改一個文件“appConfig.go”。
對於日志記錄,整個程序我只想要一個logger實例,這樣我就可以為整個程序設置相同的日志配置。我在容器內創建了一個日志記錄器包。我還嘗試了不同的日志庫來確定哪一個是最好的,然后我創建了一個日志工廠,以便將來更容易添加新的日志記錄器。有關詳細信息,請閱讀日志管理⁷。
源程序:
完整的源程序鏈接 github: https://github.com/jfeng45/servicetmpl
索引:
[1] ozzo-validation
[2] YAML support for the Go language
[4]Go Microservice with Clean Architecture: Dependency Injection
[5] How I pass around shared resources (databases, configuration, etc) within Golang projects
[6]viper
[7]Go Microservice with Clean Architecture: Application Logging