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