前言
通常有些規模的專案會透過架構分層的方式來管理,而近期在研究如何更好的透過「依賴注入」替換模塊並實現更乾淨的測試。架構分層的概念可以參考之前寫過的: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), }}二、透過介面實現依賴反轉
定義介面讓高階模組(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}}三、建構子注入(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) // ...}四、DI 帶來的測試優勢
透過 DI 擴充變得靈活後好像很厲害,但也增加了複雜度,如果初期只有一種實作幹嘛搞得這麼複雜?還要遵循這些模式?
的確,這是可以取捨的地方,但開發中有一個因素值得你考慮:「測試」。
通常為了關注點分離與效率方面的考量,測試代碼通常存在邊界,在不同層級需要撰寫對應的測試代碼,而透過 DI 可以輕易的替換掉注入的模組,只要遵循介面即可。
import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock")
// Mock 實作 Servicetype 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)}總結
我上傳了 go-gin-testing-todos🔗 範例透過 DI 實踐測試架構
在 Go 中透過多形 interface 的方式來實作 DI。讓我不太熟悉的一點是改造後每次進入 TodoService 都會看到 interface 定義而非實作,會需要在編輯器上找到 Go implementation 查看存在的實作。