Dependency Inversion, Make Testable Program!

透過依賴注入達成依賴反轉,使替換程式模組測試更省事!

前言

通常有些規模的專案會透過架構分層的方式來管理,而近期在研究如何更好的透過「依賴注入」替換模塊並實現更乾淨的測試。架構分層的概念可以參考之前寫過的:Express.js 入門建構 MVC 範例

問題:高階模組依賴低階模組

試想筆電(高階模組)充電線(低階模組)是直接焊死在機器上,雖然簡單方便但⋯⋯

  • 想換不同充電口 → 要打開整台筆電改造
  • 電壓不同 → 要打開整台筆電改造

或像是程式上傳圖片功能⋯⋯

  • 想換不同的存儲供應商 → 要編輯整個上傳圖片功能
  • 想擴充不同存儲供應商 → 要拆開解讀整個上傳圖片功能

高層級的模組不應該和低層級實作牽連,就像學開車時不會怎麼開特定品牌的車輛而是這些底層模組「實作」了特定介面,像是「開車技巧(方向盤、煞車、排檔⋯⋯)」。

只要對程式進行修改就代表更大的理解負擔與相互牽連,為了達成「對修改封閉,對擴張開放」的設計,適當的透過創造抽象介面是一種手段提高程式的擴展性。

依賴反轉原則(Dependency Inversion Principle, DIP)

前面案例提到的問題解決方案其實就是讓模組依賴「介面」而非「實作」,也就是「依賴反轉原則」:

高階模組 → 實作
高階模組 → 介面 ← 實作
A. 高階依賴低階
BadCoffeeMachine → NespressoCapsule
→ ElectricHeater
B. 依賴反轉設計
CoffeeMachine → CoffeeBeans ← NespressoCapsule
→ WaterHeater ← StarbucksBeans
← LocalRoastBeans
← ElectricHeater
← GasHeater
← InductionHeater

什麼是依賴注入(Dependency Injection)

前面提到可以透過依賴反轉來達成更高的靈活性,而依賴注入就是達成的手段之一

依賴注入是一種控制權反轉(IoC)的實作方式,將「依賴的建立」這件事交由外部負責。

「內部」可以是 class、function、struct⋯⋯ 等程式區塊,而注入邏輯意味著不要在模組內自己建立依賴物件,而是由外部傳入。

  • 在沒有 DI 時:
    • 模組自己決定要使用哪個依賴
  • 在使用 DI 後:
    • 模組只聲明它需要什麼,由外部決定提供什麼

實際案例:透過 DI 打造更易於擴張的架構

以下使用 Go 的增刪讀改待辦事項 API 作為範例,展示如何透過 DI 達成更靈活的程式架構。

一、高階模組依賴低階模組

原先直接在 Controller 內部建立 Service,導致緊密耦合,造成寫測試想要 Mock Service 或改動 DB 都要動到 Controller:

// Controller 直接依賴具體 TodoService 實作
type TodoController struct {
service *TodoService
}
func NewTodoService(db *mongo.Database) TodoService {
return &todoService{
collection: db.Collection("todos"),
}
}
func NewTodoController(db *mongo.Database) *TodoController {
// Controller 內部自己建立依賴,難以替換
return &TodoController{
service: NewTodoService(db),
}
}

❌ 無 DI

直接依賴

直接依賴

TodoController

TodoService

MongoDB

二、透過介面實現依賴反轉

定義介面讓高階模組(Controller)依賴抽象,使 Controller 不在乎 Service 如何實作,也能根據抽象更靈活的替換測試用的 Mock Service:

// 定義介面(抽象)
type TodoService interface {
Create(todo *model.Todo) error
GetAll() ([]model.Todo, error)
GetByID(id string) (*model.Todo, error)
Update(id string, todo *model.Todo) error
Delete(id string) error
}
// Controller 依賴介面而非具體實現
type TodoController struct {
service TodoService
}
func NewTodoController(s TodoService) *TodoController {
return &TodoController{service: s}
}

✅ 有 DI - 依賴介面

依賴

實作

TodoController

TodoService
interface

TodoService
implementation

三、建構子注入(Constructor Injection)啟動

  • 建構子:建立物件時執行的函數
  • 建構子注入 = 在建構子裡把需要的東西(依賴)傳進去

在這裡就只是單純一個函式初始化組裝依賴產出要用的 Controller 而已,一種達成 DI 的方法。

func main() {
// 1. 設置 DB
client, _ := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
db := client.Database("todo_db")
// 2. 組裝依賴
todoService := service.NewTodoService(db)
todoController := controller.NewTodoController(todoService)
// 3. 設定路由
r := gin.Default()
r.POST("/todos", todoController.Create)
r.GET("/todos", todoController.GetAll)
// ...
}

main.go

注入

注入

註冊

TodoController

MongoDB

TodoService
實現

Gin Router

四、DI 帶來的測試優勢

透過 DI 擴充變得靈活後好像很厲害,但也增加了複雜度,如果初期只有一種實作幹嘛搞得這麼複雜?還要遵循這些模式?

的確,這是可以取捨的地方,但開發中有一個因素值得你考慮:「測試」。

通常為了關注點分離與效率方面的考量,測試代碼通常存在邊界,在不同層級需要撰寫對應的測試代碼,而透過 DI 可以輕易的替換掉注入的模組,只要遵循介面即可。

import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mock 實作 Service
type MockTodoService struct {
mock.Mock
}
func (m *MockTodoService) Create(todo *model.Todo) error {
args := m.Called(todo)
return args.Error(0)
}
// ... 其他方法實現
func TestCreateTodo(t *testing.T) {
// GIVEN: 建立 Mock 並設定預期行為
mockService := new(MockTodoService)
mockService.On("Create", mock.Anything).Return(nil)
// 注入 MockService 到 Controller
controller := NewTodoController(mockService)
// WHEN: 執行測試
// ... 發送 HTTP 請求
// THEN: 驗證
mockService.AssertExpectations(t)
}

🧪 測試環境

TodoController

MockTodoService

🚀 生產環境

TodoController

TodoService

MongoDB

總結

我上傳了 go-gin-testing-todos🔗 範例透過 DI 實踐測試架構

在 Go 中透過多形 interface 的方式來實作 DI。讓我不太熟悉的一點是改造後每次進入 TodoService 都會看到 interface 定義而非實作,會需要在編輯器上找到 Go implementation 查看存在的實作。

DI 架構

依賴

實現

實現

TodoController

TodoService
Interface

TodoService

MockTodoService

傳統緊耦合架構

依賴

TodoController

TodoService

延伸閱讀