我是如何組織 Go 程式碼的(目錄結構 依賴注入 wire)

2023-07-19 06:01:06

背景

對於大多數 Gopher 來說,編寫 Go 程式會直接在目錄建立 main.go,xxx.go,yyy.go……

不是說不好,對於小型工程來說,簡單反而簡潔明瞭,我也提倡小工程沒必要整一些花裡胡哨的東西。

畢竟 Go 語言作為現代微服務的開發新寵,各個方面都比較自由,沒有很多約束。我想,這也是它充滿活力的原因。

對於大型工程而言,或者團隊共同作業中,沒有明確的規範,只會使得專案越來越凌亂……

因為每個人的心中對程式碼的管理、組織,對業務的理解不完全是一致的。

我參考了 非官網社群的規範 以及公司的規範,談談平時是怎麼組織的,希望我的理解,對大家有所幫助。

目錄結構範例

.
├── api                          路由與服務掛接
├── cmd                          程式入口,可以有多個程式
│   └── server
│       ├── inject               自動生成依賴注入程式碼
│       └── main.go
├── config                       設定相關資料夾
├── internal                     程式內部邏輯
│   ├── database
│   │   ├── redis.go 
│   │   └── mysql.go
│   ├── dao                      資料庫操作介面/實現
│   │   ├── dao_impls
│   │   │   └── user_impls.go
│   │   └── user.go              使用者 DAO 介面
│   ├── svc_impls                服務介面實現
│   │   ├── svc_auth
│   │   └── svc_user
│   └── sdks                     外部 SDK 依賴
└── service                      服務介面定義
        ├── auth.go              認證服務定義
        └── user.go              使用者服務定義

面向介面程式設計

正如你所看到的,我的目錄結構將介面和實現分開存放了。

根據依賴倒置原則(Dependence Inversion Principle),物件應依賴介面,而不是依賴實現。

依賴介面帶來的好處有很多(當然缺點就是你要多寫些程式碼):

  • 哪天看到某實現有問題,你可以更換一個實現(套娃大法)
  • 編寫程式碼的時候,你可以站在更高的視角看待問題,而不是陷入細節中
  • 編碼時,因為介面已經定義好了,你可以一直在當前的模組寫下去,不著急寫依賴的模組的實現

比如我有個 Deployment 常駐程序管理服務,我是這樣定義的:

type Service struct {
    DB                  isql.GormSQL
    DaoGroup            dao.Group
    DaoDeployment       dao.Deployment
    DaoDeploymentStates dao.DeploymentState
    ProcessManager      sdks.ProcessManager
    ServerManager       sdks.ServerManager
    ServerSelector      sdks.ServerSelector
}

該 struct 的成員都是介面。

目前 dao.* 都是在 MySQL 裡面,但不排除哪天,我會把 dao.DeploymentState 放到 Redis 儲存,此時只需重新實現 CURD 四個藉口即可。

因為程序的狀態是頻繁更新的,資料量大的時候,放 MySQL 不太合適。

我們再看看 ProcessManager,它也是一個 interface:

type ProcessManager interface {
    StartProcess(ctx context.Context, serverIP string, params ProcessCmdArgs) (code, pid int, err error)
    CheckProcess(ctx context.Context, serverIP string, pid int) (err error)
    InfoProcess(ctx context.Context, serverIP string, pid int) (info jobExecutor.ProcessInfoResponse, err error)
    KillProcess(ctx context.Context, serverIP string, pid int) (err error)
    IsProcessNotRunningError(err error) bool
}

我編碼的過程中,只要先想好每個模組的入參和出參,ProcessManager 到底要長什麼樣,我到時候再寫!

本地測試時,我也可以寫個 mock 版的 ProcessManager,生產的時候是另一個實現,如:

func NewProcessManager(config sdks.ProcessManagerConfig) sdks.ProcessManager {
    config.Default()
    if config.IsDevelopment() {
        return &ProcessManagerMock{config: config}
    }
    return &ProcessManager{config: config}
}

確實是要多寫點程式碼,但是你習慣了之後,你肯定會喜歡上這種方式。

如果你眼尖,你會發現 NewProcessManager 也是依賴倒置的!它依賴 sdks.ProcessManagerConfig 設定:

func GetProcessManagerConfig() sdks.ProcessManagerConfig {
    return GetAcmConfig().ProcessManagerConfig
}

而 GetProcessManagerConfig 又依賴 AcmConfig 設定:

func GetAcmConfig() AcmConfig {
    once.Do(func() {
        err := cfgLoader.Load(&acmCfg, ...)
        if err != nil {
            panic(err)
        }
    })
    return acmCfg
}

也就是說,程式啟動時候,可以初始化一個應用設定,有了應用設定,就有了程序管理器,有了程序管理器,就有了常駐程序管理服務……

這個時候你會發現,自己去組織這顆依賴樹是非常痛苦的,此時我們可以藉助 Google 的 wire 依賴注入程式碼生成器,幫我們把這些瑣事做好。

wire

我以前寫 PHP 的時候,主要是使用 Laravel 框架。

wire 和這類框架不同,它的定位是程式碼生成,也就是說在編譯的時候,就已經把程式的依賴處理好了。

Laravel 的依賴注入,在 Go 的世界裡對應的是 Uber 的 dig 和 Facebook 的 inject,都是使用 反射 機制實現依賴注入的。

在我看來,我更喜歡 wire,因為很多東西到了執行時,你都不知道具體是啥依賴……

基於程式碼生成的 wire 對 IDE 十分友好,容易偵錯。

要想使用 wire,得先理解 Provider 和 Injector:

Provider: a function that can produce a value. These functions are ordinary Go code.

Injector: a function that calls providers in dependency order. With Wire, you write the injector’s signature, then Wire generates the function’s body.

Provider 是一個可以產生值的函數——也就是我們常說的建構函式,上面的 NewProcessManager 就是 Provider。

Injector 可以理解為,當很多個 Provider 組裝在一起的時候,可以得到一個管理物件,這個是我們定義的。

比如我有個 func NewApplicaion() *Applicaion 函數,

它依賴了 A、B、C,

而 C 又依賴了我的 Service,

Service 依賴了 DAO、SDK,

wire 就會自動把 *Applicaion 需要 New 的物件都列舉出來,

先 NewDao,

然後 NewSDK,

再 NewService,

再 NewC,

最後得到 *Applicaion 返回給我們。

此時,NewApplicaion 就是 Injector,不知道這樣描述能不能聽懂!

實在沒明白的,可以看下程式碼,這些不是手打的,而是 wire 自動生成的哦~

func InitializeApplication() (*app.Application, func(), error) {
    extend := app.Extend{}
    engine := app.InitGinServer()
    wrsqlConfig := config.GetMysqlConfig()
    gormSQL, cleanup, err := database.InitSql(wrsqlConfig)
    if err != nil {
        return nil, nil, err
    }
    daoImpl := &dao_group.DaoImpl{}
    cmdbConfig := config.GetCmdbConfig()
    rawClient, cleanup2 := http_raw_client_impls.NewHttpRawClient()
    cmdbClient, err := cmdb_client_impls.NewCmdbCli(cmdbConfig, rawClient)
    if err != nil {
        cleanup2()
        cleanup()
        return nil, nil, err
    }
    serverManagerConfig := config.GetServerManagerConfig()
    jobExecutorClientFactoryServer := job_executor_client_factory_server_impls.NewJobExecutorClientFactoryServer(serverManagerConfig)
    serverManager := server_manager_impls.NewServerManager(gormSQL, daoImpl, cmdbClient, serverManagerConfig, jobExecutorClientFactoryServer)
    service := &svc_cmdb.Service{
        ServerManager: serverManager,
    }
    svc_groupService := &svc_group.Service{
        DB:            gormSQL,
        DaoGroup:      daoImpl,
        ServerManager: serverManager,
    }
    dao_deploymentDaoImpl := &dao_deployment.DaoImpl{}
    dao_deployment_stateDaoImpl := &dao_deployment_state.DaoImpl{}
    processManagerConfig := config.GetProcessManagerConfig()
    jobExecutorClientFactoryProcess := job_executor_client_factory_process_impls.NewJobExecutorClientFactoryProcess(serverManagerConfig)
    jobExecutorClientFactoryJob := job_executor_client_factory_job_impls.NewJobExecutorClientFactoryJob(serverManagerConfig)
    processManager := process_manager_impls.NewProcessManager(processManagerConfig, jobExecutorClientFactoryProcess, jobExecutorClientFactoryJob)
    serverSelector := server_selector_impls.NewMultiZonesSelector()
    svc_deploymentService := &svc_deployment.Service{
        DB:                  gormSQL,
        DaoGroup:            daoImpl,
        DaoDeployment:       dao_deploymentDaoImpl,
        DaoDeploymentStates: dao_deployment_stateDaoImpl,
        ProcessManager:      processManager,
        ServerManager:       serverManager,
        ServerSelector:      serverSelector,
    }
    svc_deployment_stateService := &svc_deployment_state.Service{
        DB:                              gormSQL,
        ProcessManager:                  processManager,
        DaoDeployment:                   dao_deploymentDaoImpl,
        DaoDeploymentState:              dao_deployment_stateDaoImpl,
        JobExecutorClientFactoryProcess: jobExecutorClientFactoryProcess,
    }
    authAdminClientConfig := config.GetAuthAdminConfig()
    authAdminClient := auth_admin_client_impls.NewAuthAdminClient(authAdminClientConfig, rawClient)
    redisConfig := config.GetRedisConfig()
    redis, cleanup3, err := database.InitRedis(redisConfig)
    if err != nil {
        cleanup2()
        cleanup()
        return nil, nil, err
    }
    svc_authService := &svc_auth.Service{
        AuthAdminClient: authAdminClient,
        Redis:           redis,
    }
    dao_managersDaoImpl := &dao_managers.DaoImpl{}
    kserverConfig := config.GetServerConfig()
    svc_heartbeatService := &svc_heartbeat.Service{
        DB:                             gormSQL,
        DaoManagers:                    dao_managersDaoImpl,
        ServerConfig:                   kserverConfig,
        JobExecutorClientFactoryServer: jobExecutorClientFactoryServer,
    }
    portalClientConfig := config.GetPortalClientConfig()
    portalClient := portal_client_impls.NewPortalClient(portalClientConfig, rawClient)
    authConfig := config.GetAuthConfig()
    svc_portalService := &svc_portal.Service{
        PortalClient: portalClient,
        AuthConfig:   authConfig,
        Auth:         svc_authService,
    }
    apiService := &api.Service{
        CMDB:            service,
        Group:           svc_groupService,
        Deployment:      svc_deploymentService,
        DeploymentState: svc_deployment_stateService,
        Auth:            svc_authService,
        Heartbeat:       svc_heartbeatService,
        Portal:          svc_portalService,
    }
    ginSvcHandler := app.InitSvcHandler()
    grpcReportTracerConfig := config.GetTracerConfig()
    configuration := config.GetJaegerTracerConfig()
    tracer, cleanup4, err := pkgs.InitTracer(grpcReportTracerConfig, configuration)
    if err != nil {
        cleanup3()
        cleanup2()
        cleanup()
        return nil, nil, err
    }
    gatewayConfig := config.GetMetricsGatewayConfig()
    gatewayDaemon, cleanup5 := pkgs.InitGateway(gatewayConfig)
    application := app.NewApplication(extend, engine, apiService, ginSvcHandler, kserverConfig, tracer, gatewayDaemon)
    return application, func() {
        cleanup5()
        cleanup4()
        cleanup3()
        cleanup2()
        cleanup()
    }, nil
}

wire 怎麼用倒是不難,推薦大家使用 Provider Set 組合你的依賴。

可以看下面的例子,新建一個 wire.gen.go 檔案,注意開啟 wireinject 標籤(wire 會識別該標籤並組裝依賴):

//go:build wireinject
// +build wireinject

package inject

import (
   "github.com/google/wire"
)

func InitializeApplication() (*app.Application, func(), error) {
   panic(wire.Build(Sets))
}

func InitializeWorker() (*worker.Worker, func(), error) {
   panic(wire.Build(Sets))
}

InitializeApplication:這個就是 Injector 了,表示我最終想要 *app.Application,並且需要一個 func(),用於程式退出的時候釋放資源,如果中間出現了問題,那就返回 error 給我。

wire.Build(Sets) :Sets 是一個依賴的集合,Sets 裡面可以套 Sets:

var Sets = wire.NewSet(
    ConfigSet,
    DaoSet,
    SdksSet,
    ServiceSet,
)

var ServiceSet = wire.NewSet(
    // ...
    wire.Struct(new(svc_deployment.Service), "*"),
    wire.Bind(new(service.Deployment), new(*svc_deployment.Service)),

    wire.Struct(new(svc_group.Service), "*"),
    wire.Bind(new(service.Group), new(*svc_group.Service)),
)

注:wire.Structwire.Bind 的用法看檔案就可以了,有點像 Laravel 的介面繫結實現。

此時我們再執行 wire 就會生成一個 wire_gen.go 檔案,它包含 !wireinject 標籤,表示會被 wire 忽略,因為是 wire 生產出來的!

//go:build !wireinject
// +build !wireinject

package inject

func InitializeApplication() (*app.Application, func(), error) {
    // 內容就是我上面貼的程式碼!
}

感謝公司的大神帶飛,好記性不如爛筆頭,學到了知識趕緊記下來!


文章來源於本人部落格,釋出於 2020-12-05,原文連結:https://imlht.com/archives/223/