3、Go中的手动依赖注入:构造函数注入与接口抽象实践

手动依赖注入是理解 DI 原理的最佳起点。在 Go 中,不依赖框架,仅通过构造函数和接口就能构建出可测试、可扩展的架构。本章将深入实践这一核心模式。

3.1 为什么需要手动注入?

在大型项目中,对象之间往往存在复杂的依赖关系。如果直接在结构体内部 new 出依赖对象,会导致代码紧耦合、难以测试。手动依赖注入通过将依赖的创建与使用分离,让每个组件只关注自己的职责。

对比维度 紧耦合(无 DI) 手动 DI(构造函数注入)
测试难度 高:无法替换真实依赖 低:可注入 Mock 对象
代码复用 低:依赖硬编码 高:依赖可替换
重构成本 高:修改一处影响全局 低:仅需调整注入点
可读性 依赖关系隐式 依赖关系显式

3.2 核心模式:接口抽象 + 构造函数注入

手动 DI 的黄金组合是:定义接口 作为契约,构造函数接收接口参数 作为依赖注入点。这样,调用方可以自由选择具体实现。

3.2.1 定义接口

// store/user_store.go
package store

type UserStore interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

3.2.2 实现具体类型

// store/mysql_user_store.go
package store

type MySQLUserStore struct {
    db *sql.DB
}

func NewMySQLUserStore(db *sql.DB) *MySQLUserStore {
    return &MySQLUserStore{db: db}
}

func (m *MySQLUserStore) FindByID(id int) (*User, error) {
    // 真实数据库查询
    row := m.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)
    // ...
    return &User{}, nil
}

func (m *MySQLUserStore) Save(user *User) error {
    // 真实数据库插入
    return nil
}

3.2.3 构造函数注入

// service/user_service.go
package service

type UserService struct {
    store store.UserStore  // 依赖接口,而非具体实现
}

// NewUserService 通过构造函数注入依赖
func NewUserService(s store.UserStore) *UserService {
    return &UserService{store: s}
}

func (s *UserService) GetUser(id int) (*store.User, error) {
    return s.store.FindByID(id)
}
关键原则: 构造函数 NewUserService 只接收接口类型 store.UserStore,不关心底层是 MySQL、PostgreSQL 还是内存存储。

3.3 手动组装:在 main 函数中构建依赖图

手动 DI 的组装点通常位于应用程序的入口(如 main.go)。这里负责创建所有具体实例,并按依赖关系注入。

// cmd/server/main.go
package main

func main() {
    // 1. 创建底层依赖
    db := initDatabase()  // *sql.DB
    userRepo := store.NewMySQLUserStore(db)

    // 2. 创建业务服务,注入依赖
    userSvc := service.NewUserService(userRepo)

    // 3. 创建 HTTP 处理器,注入服务
    userHandler := handler.NewUserHandler(userSvc)

    // 4. 启动服务器
    router := chi.NewRouter()
    router.Get("/users/{id}", userHandler.GetUser)
    http.ListenAndServe(":8080", router)
}
实践建议: 将 main.go 中的组装逻辑保持简洁。如果依赖图过于复杂,可以考虑使用 wire 等代码生成工具,但本章聚焦手动方式以理解本质。

3.4 手动 DI 的依赖关系图

下图展示了手动 DI 模式下,依赖从入口到业务层的流动方向。箭头表示“依赖”关系,而非数据流。

手动依赖注入:依赖关系图 main.go(组装器) MySQLUserStore UserStore 接口 MockUserStore UserService UserHandler 依赖注入方向 main 创建具体实现 通过接口注入到消费者

3.5 测试实践:用 Mock 替换真实依赖

手动 DI 的最大优势在于测试。我们可以轻松创建一个 Mock 实现,注入到 UserService 中进行单元测试。

// store/mock_user_store.go
package store

type MockUserStore struct {
    FindByIDFunc func(id int) (*User, error)
    SaveFunc     func(user *User) error
}

func (m *MockUserStore) FindByID(id int) (*User, error) {
    return m.FindByIDFunc(id)
}

func (m *MockUserStore) Save(user *User) error {
    return m.SaveFunc(user)
}
// service/user_service_test.go
package service

func TestGetUser_Success(t *testing.T) {
    // 准备 Mock
    mockStore := &store.MockUserStore{
        FindByIDFunc: func(id int) (*store.User, error) {
            return &store.User{ID: id, Name: "Alice"}, nil
        },
    }

    // 注入 Mock
    svc := NewUserService(mockStore)

    // 执行测试
    user, err := svc.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
}
注意: 手动 Mock 适用于接口方法较少的场景。如果接口方法超过 5 个,建议使用 gomock 或 testify/mock 等框架自动生成 Mock 代码,减少重复劳动。

3.6 手动 DI 的优缺点总结

优势 劣势
零依赖框架,学习成本低 依赖图复杂时,main 函数膨胀
依赖关系完全显式,代码可读性高 需要手动维护所有注入点
测试友好,可任意替换实现 大型项目容易产生“构造函数参数过多”问题
编译期类型安全 重构时需手动调整所有调用方

3.7 何时使用手动 DI?

  • 中小型项目(10 万行代码以内):手动 DI 足够清晰,无需引入额外框架。
  • 团队对 DI 概念尚不熟悉:手动方式有助于理解依赖注入的本质。
  • 对编译速度和二进制大小敏感:手动 DI 无运行时反射开销。
  • 作为学习过渡:先掌握手动 DI,再平滑迁移到 wire 等自动化工具。
核心结论: 手动依赖注入 + 接口抽象是 Go 项目中最基础、最可靠的解耦手段。它不依赖任何第三方库,却能为后续的测试、重构和架构演进打下坚实基础。掌握这一模式,是成为 Go 架构师的第一步。