Dependency Injection - Make Swappable & Testable module!

通过依赖注入达成依赖反转,使替换程序模块测试更省事!

前言

通常有些规模的项目会通过架构分层的方式来管理,而近期在研究如何更好地通过「依赖注入」替换模块并实现更干净的测试。架构分层的概念可以参考之前写过的: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 多型的方式来实现 DI。让我不太熟悉的一点是改造后每次进入 TodoService 都会看到 interface 定义而非实现,会需要在编辑器上找到 Go implementation 查看存在的实现。

DI 架构

依赖

实现

实现

TodoController

TodoService
Interface

TodoService

MockTodoService

传统紧耦合架构

依赖

TodoController

TodoService

延伸阅读