蒼時弦也
蒼時弦也
資深軟體工程師
發表於

從 wire 學到依賴注入沒有講的事

最近在工作中對專案套用 google/wire 來處理依賴注入(DI,Dependency Injection)時,發現有非常多小細節需要注意。大多數語言其實都會碰到這類問題,然而 wire 的設計沒有採用大多數框架會提供的控制反轉(IoC,Inverse of Control)機制來處理,反而讓很多過去沒有考慮的狀況浮現出來。

什麼是依賴注入

要簡單的解釋依賴注入,就是讓物件的依賴從外部提供,而不是自己解決。舉例來說,我們有一個如下的依賴結構, UserRepository 所依賴的 DatabaseClient 應該在初始化時被提供進去,而不是自己建構出來。

圖一

用 Golang 來舉例,我們應該避免這樣實作:

 1type UserRepository struct {
 2  db *infra.Database
 3}
 4
 5func NewUserRepository(config *app.Config) (*UserRepository, error) {
 6  db, err := infra.NewDatabase(config.DatabaseURL)
 7  if err != nil {
 8    return nil, ErrUnableSetupDatabase
 9  }
10
11  return &UserRepository {
12    db: db,
13  }
14}

而是要將 infra.Database 注入到 UserRepository 裡面:

1type UserRepository struct {
2  db *infra.Database
3}
4
5func NewUserRepository(db *infra.Database) *UserRepository {
6  return &UserRepository {
7    db: db,
8  }
9}

大多數時候我們也都能考量到這點,然而這還是會有許多衍生的問題。最常見的是在圖中我們的 UserRepositoryProfileController 依賴的對象,那們我們還有可能做出這樣的設計。

 1type ProfileController struct {
 2  db *infra.Database
 3}
 4
 5func (ctrl *ProfileController) GetProfile(userId int) *ProfileData {
 6  repo := repositories.NewUserRepository(ctrl.db)
 7  // ...
 8  profile := repo.Find(userId)
 9
10  return &ProfileData {
11    // ...
12  }
13}

我們確實注入了依賴沒錯,然而這個依賴是 ProfileController 提供的會是問題的原因,假設我們的依賴是非常深的狀況,那麼每一層物件都需要攜帶這個 infra.Database 物件,直到所有的依賴被解決。

對於 ProfileController 來說,他的依賴是 UserRepository 那麼他應該要接受的是被注入這個物件,而不是這個物件的依賴。

 1type ProfileController struct {
 2  repo *UserRepository
 3}
 4
 5func (ctrl *ProfileController) GetProfile(userId int) *ProfileData {
 6  // ...
 7  profile := repo.Find(userId)
 8
 9  return &ProfileData {
10    // ...
11  }
12}

要解決這樣的問題,就是控制反轉出場的時機。

控制反轉的任務

經常跟依賴注入一起討論的控制反轉,通常是「控制反轉容器」的實現,我們拿 C# 的 Niject 來當作例子,文件提供了一個實作範例。

 1// 被注入者
 2public class Samurai {
 3    public IWeapon Weapon { get; private set; }
 4    public Samurai(IWeapon weapon) {
 5	    this.Weapon = weapon;
 6	}
 7}
 8
 9// 容器定義
10public class WarriorModule : NinjectModule
11{
12    public override void Load() {
13            this.Bind<IWeapon>().To<Sword>();
14    }
15}

我們會在應用啟動時,建構一個容器(通常是一個)來記錄所有型別對應的關係,像是上面的例子就描述了當需要 IWeapon 時,要提供 Sword 物件進去。這個機制需要依賴程式語言的反射(Reflect)功能,在沒有反射的語言中通常就不會有這樣的實作。

例如 TypeScript 有 InversifyJS 可以使用,在 JavaScript 中就沒有對應的套件。在 Ruby 裡面也沒有,想要達到類似的效果,以 dry-container 這個 Gem 來說,他只能利用 Key-Value 的的方式來模仿這件事情。

這個方式我們需要先向控制反轉容器進行註冊,因此所有物件如何建構都是在啟動時期就被確定下來的,何時被實例化就要看容器的設計,以前面提及的 Niject 和 InversifyJS 為例子,他們都允許延遲生成(在有實際需要時才實例化)

經常拿來跟 wire 比較的 uber-go/fx 就屬於控制反轉容器的實作。

控制反轉的另一種應用,則是服務定位(Service Locator)模式,跟依賴注入不同的地方是我們會在需要用到的時候呼叫 Locator 物件來取的想要的物件,這個應用主要被使用在 Router 上,跟前面提到的 Key-Value 對應方式有點類似。

下面是幾年前我用 InversifyJS 來實現 Discord Bot 機器人命令功能的實作:

 1  async on(interaction: Interaction) {
 2    if (!interaction.isChatInputCommand()) {
 3      return
 4    }
 5
 6    const commandClass = this.routes.get(interaction.commandName)
 7    if(!commandClass) {
 8      return
 9    }
10
11    try {
12      const command = container.resolve(commandClass)
13      command.setInteraction(interaction)
14      await command.execute()
15    } catch(error) {
16      console.error(error)
17      await interaction.reply({ content: '我故障了!', ephemeral: true });
18    }
19  }

上面的例子,我們會去詢問這個命令(如:/list)對應的類別(Class)是什麼,然後讓控制反轉容器嘗試將這個命令物件實例化,在這種狀況下使用服務定位的技巧就沒有太大的問題,然而在處理依賴注入的時候則要避免這種方式。

正因如此,在 uber-go/fx 底層實際上用來處理依賴注入的 uber-go/dig 是有明確的要求我們不應該用於服務定位的情況。

Clean Architecture 的依賴

去年讀完 Clean Architecture 後有寫了這篇讀 Clean Architecture 學習依賴管理來說明對於書中的內容的理解,如果把 Clean Architecture 的觀念在放到依賴注入中,又會發現有更多的議題需要討論。

在 Clean Architecture 提到了元件(Component)應該要能夠被抽換的,因此我們需要能夠區分出哪些物件要被分組在一起,構成一個元件。將前面說明依賴注入的插圖根據實際的情境調整,會得到這樣的關係圖。

圖2

這張圖中先簡單的依照 Clean Architecture 舉例的類型把物件稍微分類出來,這時候我們會注意到 UserRepository 已經變成了一個介面(Interface)並且由 PostgresUsers 來實作。

再繼續觀察 Infrastructure 的依賴狀況,扣掉 PostgresUsers 的情況是完全只對這個分組裡面的物件互相依賴,在 UseCases 裡面則是指定義了介面,根據需要注入到裡面。這也是依賴注入會使用的技巧之一,這個方式也讓依賴關係被消除。

有一篇文章 Using interfaces in Go the right way 提到,在 Golang 裡面介面應該要由 Consumer(消費者)來定義,上圖的例子是 ProfileUseCase 在使用 UserRepository 所以才會被劃分在 UseCase 群組裡面,被當作同一個套件。

假設我們繼續延伸上述這張以 Clean Architecture 作為參考的依賴關係圖,會發現大多數情況的依賴關係都不會超過一層,以 UseCases 來說,他的依賴固定是 Entites 的內容,而 Controllers 的依賴就固定會是 UseCases

如果從這樣的角度去看 Domain-Driven Design(DDD,領域驅動設計)提到到的 Layered Architecture(階層式架構)是有其道理在的,越上層的物件是對下一層的物件的封裝。在最上層則是表現層(Presentation Layer)或稱 UI Layer(使用者介面),剛好就是曝露給使用者操作的意思。

Wire 的使用

根據前面提到的限制跟使用方式,就不難想像 wire 應該如何使用,我們會去使用 wire 的目的就是要希望可以輕鬆的將整個應用的依賴一次性的建構完畢,如果要自己實作的話會變成這樣。

 1func main() {
 2  config := &infra.Config{
 3    DatabaseURL: "postgres://...",
 4  }
 5  dbClient, err := infra.NewDatabase(config)
 6  if err != nil {
 7    panic(err)
 8  }
 9  // ...
10  users := postgres.NewUsers(dbClient)
11  profileUseCase := usecases.NewProfile(users)
12  profileCtrl := controllers.NewProfile(profileUseCase)
13  app := app.New(profileCtrl)
14  // ...
15}

不用太過於複雜的應用,就會需要花上非常多心力去維護。那麼,使用 wire 之後,就可以利用 wire.Buildwire.NewSet 來維護,就可以整理成像這樣的實作。

 1var repoSet = wire.NewSet(
 2  postgres.NewUsers,
 3  wire.Bind(new(usecases.UserRepository), new(*postgres.Users)), // 綁定介面
 4)
 5
 6// ...
 7
 8func initApp(cfg *infra.Config) (*app.Application, error) {
 9  wire.Build(
10    infraSet,
11    repoSet,
12    usecaseSet,
13    ctrlSet,
14    app.New,
15  )
16
17  return nil, nil
18}

之後只需要維護這些物件的建構函示列表,就能讓 wire 自動的生成所有初始化的函示,透過 Set 的組合變化,還能做出些微調,而不需要重新處理。

在依賴注入的處理中跟 Clean Architecture 描述的依賴關係必定是 DAG(Directed Acyclic Graph,有向無環圖)是一樣的,我們所有物件的依賴最後一定會有終點,如果沒有終點的話則會造成循環依賴(Circular Dependency)而無法順利初始化。以我們上面的例子,initApp 的依賴終點是 infra.Config 這個物件,也符合我們「提供設定初始化應用程式」的期待,其他的細節都被隱藏起來。

除了上述的基本使用方式外,在 wire 也有一些限制,像是每一種型別只能「提供一次」那麼下面這樣的處理就無法被實現。

 1// 依賴
 2func NewA(name string) *A {
 3  // ...
 4}
 5
 6func NewB(name string) *B {
 7  // ...
 8}
 9
10func NewApp(a *A, b *B) *App {
11  return &App{a, b}
12}
13
14// Wire 實作
15
16func initApp() *App {
17  wire.Build(
18    NewA, // name = "A"
19    NewB, // name = "B"
20    NewApp,
21  )
22  return nil
23}

我們除了選擇讓 name 是相同的之外,對 wire 來說是無法區分出 NewANewB 參數的差異來注入不一樣的數值,這也讓這種我們很常會在 Golang 套件看到的實作能看出背後的目的。

1func NewA(config *ConfigA) {
2  // ...
3}
4
5func NewB(config *ConfigB) {
6  // ...
7}

當我們用一個結構定義後,因為他們具備了不同的型別,對 wire 來說就可以區分出來,那麼我們就可以像這樣子去實作。

 1func initApp() *App {
 2  wire.Build(
 3    wire.Value(ConfigA{Name: "A"}), // 固定數值
 4    wire.Struct(new(ConfigB), "B"), // 動態產生
 5    NewA, // name = "A"
 6    NewB, // name = "B"
 7    NewApp,
 8  )
 9
10  return nil
11}

對應這樣的情境,如果是固定的設定值,可以用 wire.Value 或者 wire.Struct 的方式提供,或者定義一個新的工廠來生成這個設定。

 1func newConfigA(cfg *Config) *ConfigA {
 2  return &ConfigA{
 3    Name: cfg.Prefix + "A",
 4  }
 5}
 6
 7// ...
 8
 9func initApp(config *Config) *App {
10  wire.Build(
11    newConfigA,
12    newConfigB,
13    NewA, // name = "Example_A"
14    NewB, // name = "Example_B"
15    NewApp,
16  )
17
18  return nil
19}

基於這個技巧我們可以用來針對 Middleware 這類情境,還能做出類似這樣的效果。

 1func provideMiddleware(
 2  config *Config,
 3  jwtAuth *jwt.Authenticator
 4) []OptionFn {
 5  options := make([]OptionFn, 0)
 6
 7  if config.AuthEnabled {
 8    options = append(
 9      options,
10      WithAuthenticator(jwtAuth), // 實作 `server.Use(xxx)` 啟用 Middleware
11    )
12  }
13
14  // ...
15
16  return options
17}
18
19func newHttpServer(options ...OptionFn) *echo.Echo {
20  server := echo.New()
21
22  for _, fn := range options {
23    fn(server)
24  }
25
26  return server
27}
28
29func initApp(config *Config) *App {
30  wire.Build(
31    provideMiddleware,
32    newHttpServer,
33    newApp,
34  )
35
36  return nil
37}

像這樣子調整後,我們就能更輕鬆的提供初始化的調整選項,甚至直接在 initApp() 裡面注入對應的 Hook 來讓使用者自行定義。

依賴注入看起來是個很簡單的概念,在實作上卻有許多跟架構規劃問題上互相關聯的問題存在,如果沒有想清楚就很容易規劃了錯誤的依賴關係,而讓系統的複雜度增加。

邊界的規劃也是很重要的,像是 UserRepository 的介面被劃分出來後,我們在單元測試的時候只需要注入一個 Mock(造假)的物件即可,而不需要把完整的依賴搭建出來,也能讓可測試性獲得非常大的改善。