清晰架構(Clean Architecture)的一個理念是隔離程式的框架,使框架不會接管你的應用程式,而是由你決定何時何地使用它們。在本程式中,我特意不在開始時使用任何框架,因此我可以更好地控制程式結構。只有在整個程式結構佈局完成之後,我才會考慮用某些庫替換本程式的某些元件。這樣,引入的框架或第三方庫的影響就會被正確的依賴關係所隔離。目前,除了logger,資料庫,gRPC和Protobuf(這是無法避免的)之外,我只使用了兩個第三方庫ozzo-validation¹和YAML²,而其他所有庫都是Go的標準庫。

你可以使用本程式作為構建應用程式的基礎。你可能會問,那麼本框架豈不是要接管整個應用程式嗎?是的。但事實是,無論是你自建框架還是引進第三方框架,你都需要一個基本框架作為構建應用程式的基礎。該基礎需要具有正確的依賴性和可靠的設計,然後你可以決定是否引入其他庫。你當然可以自己建立一個框架,但你最終可能會花費大量的時間和精力來完善它。你也可以使用本程式作為起點,而不是構建自己的專案,從而為你節省時間和精力。

程式容器是專案中最複雜的部分,是將應用程式的不同部分粘合在一起的關鍵元件。本程式的其他部分是直截了當且易於理解的,但這一部分不是。好訊息是,一旦你理解了這一部分,那麼整個程式就都在掌控之中。

容器包(“container” package)的組成部分:

容器包由五部分組成:

  1. “容器”(“container”)包:它負責建立具體型別並將它們注入其他檔案。 頂級包中只有一個檔案“container.go”,它定義容器的介面。

  2. “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
  }
  1. “configs”子包:負責從YAML檔案載入程式配置,並將它們儲存到“appConfig”結構中以供容器使用。

  2. “logger”子包:它裡面只有一個檔案“logger.go”,它提供了日誌記錄器介面和一個“Log”變數來訪問日誌記錄器。 因為每個檔案都需要依賴記錄,所以它需要一個獨立的包來避免迴圈依賴。

  3. 最後一部分是不同型別的工廠(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

[3][Golang Factory Method](https://stackoverflow.com/a/49714445)

[4][Go Microservice with Clean Architecture: Dependency Injection](https://jfeng45.github.io/posts/dependency_injection/)

[5] How I pass around shared resources (databases, configuration, etc) within Golang projects

[6][viper](https://github.com/spf13/viper)

[7][Go Microservice with Clean Architecture: Application Logging](https://jfeng45.github.io/posts/go_logging_and_error_handling/)