從 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}
大多數時候我們也都能考量到這點,然而這還是會有許多衍生的問題。最常見的是在圖中我們的 UserRepository
是 ProfileController
依賴的對象,那們我們還有可能做出這樣的設計。
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)應該要能夠被抽換的,因此我們需要能夠區分出哪些物件要被分組在一起,構成一個元件。將前面說明依賴注入的插圖根據實際的情境調整,會得到這樣的關係圖。
這張圖中先簡單的依照 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.Build
和 wire.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
來說是無法區分出 NewA
和 NewB
參數的差異來注入不一樣的數值,這也讓這種我們很常會在 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(造假)的物件即可,而不需要把完整的依賴搭建出來,也能讓可測試性獲得非常大的改善。