90·工程实践高级

依赖注入与 wire

diwireinjectprovider

第 90 课 - 依赖注入与 wire

学习目标

通过本课的学习,你将能够:

  1. 理解依赖注入(DI)的核心思想及其对代码可测试性与可维护性的关键作用。
  2. 掌握使用 wire 进行编译时依赖注入的自动化流程。
  3. 能够使用 wireProvider Set 组织复杂的依赖关系。
  4. 在真实项目中实践基于接口的依赖注入,以实现松耦合设计。

核心概念

想象你正在组装一辆汽车。手动的方式是:你自己去工厂取发动机,再去找轮胎,最后亲手把它们装在一起。这种方式就是 手动依赖注入,它在简单项目中可行,但在大型项目中会导致组装逻辑(main 函数或初始化代码)变得极其臃肿和脆弱。

依赖注入(Dependency Injection, DI) 的核心思想是:不要让对象(组件)自己创建或查找其依赖项,而是由外部“组装者”在创建时将依赖项“注入”进去。这就像汽车的组装流水线,发动机、轮胎等部件都由上游工位提供,最终在总装工位“注入”到车身框架中。

在 Go 中,我们通常通过构造函数参数结构体字段来注入依赖。但手动传递和组装所有依赖依然繁琐。这时,wire 库就登场了。

wire 是一个由 Google 开发的编译时依赖注入工具。它不是反射库,而是通过代码生成(go generate)来分析你的注入逻辑,并在编译期自动生成纯粹的、可读的 Go 代码来完成组件的组装。这意味着:

  • 零运行时开销:没有反射,生成的代码就是你可能手写的代码。
  • 编译时检查:依赖缺失或类型错误在编译时就会报错。
  • 清晰可读:生成的代码可以直接查看和调试。

代码示例

让我们通过一个简单的用户注册服务来演示。我们将构建三层:存储层(Repository)、业务层(Service)和 HTTP 处理层(Handler)。

步骤 1:定义接口与实现(手动注入方式)

// storage/storage.go
package storage

import "context"

// UserRepository 定义了存储用户的接口
type UserRepository interface {
    Save(ctx context.Context, user *User) error
    GetByID(ctx context.Context, id string) (*User, error)
}

type User struct {
    ID    string
    Name  string
    Email string
}
// storage/memory.go
package storage

import (
    "context"
    "fmt"
    "sync"
)

// MemoryRepository 是 UserRepository 的一个内存实现
type MemoryRepository struct {
    mu    sync.RWMutex
    users map[string]*User
}

func NewMemoryRepository() *MemoryRepository {
    return &MemoryRepository{users: make(map[string]*User)}
}

func (r *MemoryRepository) Save(ctx context.Context, user *User) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.users[user.ID] = user
    fmt.Printf("Saved user: %s\n", user.Name)
    return nil
}

func (r *MemoryRepository) GetByID(ctx context.Context, id string) (*User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    user, exists := r.users[id]
    if !exists {
        return nil, fmt.Errorf("user %s not found", id)
    }
    return user, nil
}
// service/service.go
package service

import (
    "context"
    "fmt"
    "yourproject/storage"
)

type UserService struct {
    repo storage.UserRepository // 依赖接口,而非具体实现
}

// 注入依赖:通过构造函数传入一个实现了 UserRepository 接口的实例
func NewUserService(repo storage.UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) Register(ctx context.Context, name, email string) error {
    user := &storage.User{
        ID:    fmt.Sprintf("user_%d", len(name)), // 简化ID生成
        Name:  name,
        Email: email,
    }
    return s.repo.Save(ctx, user)
}
// handler/handler.go
package handler

import (
    "encoding/json"
    "net/http"
    "yourproject/service"
)

type UserHandler struct {
    svc *service.UserService
}

func NewUserHandler(svc *service.UserService) *UserHandler {
    return &UserHandler{svc: svc}
}

func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    if err := h.svc.Register(r.Context(), req.Name, req.Email); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusCreated)
}

此时,main 函数的组装代码是这样的:

// main.go (手动注入)
package main

import (
    "log"
    "net/http"
    "yourproject/handler"
    "yourproject/service"
    "yourproject/storage"
)

func main() {
    // 手动创建依赖链
    repo := storage.NewMemoryRepository()
    userService := service.NewUserService(repo)
    userHandler := handler.NewUserHandler(userService)

    http.HandleFunc("/register", userHandler.Register)
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

虽然清晰,但当依赖图变得复杂时,main 函数会充满这样的创建代码。

步骤 2:使用 Wire 实现自动化注入

现在,我们用 wire 来自动化这个过程。

  1. 创建注入器文件:通常命名为 wire.go

    // wire.go
    //go:build wireinject
    // +build wireinject
    
    package main
    
    import (
        "github.com/google/wire"
        "yourproject/handler"
        "yourproject/service"
        "yourproject/storage"
    )
    
    // InitializeUserHandler 是我们的“注入器函数”。
    // wire.Build 接收一组 Provider(通常是 NewXxx 函数),
    // 它会根据函数签名分析依赖关系,自动生成连接它们的代码。
    func InitializeUserHandler() (*handler.UserHandler, error) {
        wire.Build(
            storage.NewMemoryRepository, // 提供一个 UserRepository 的具体实现
            service.NewUserService,
            handler.NewUserHandler,
        )
        return nil, nil // 返回值由 wire 自动生成,这里是占位符。
    }
    

    注意:wire.go 文件需要 //go:build wireinject 构建标签,它只在 wire 命令运行时被解析,不会参与最终程序的编译。

  2. 运行 Wire 生成代码:在项目根目录执行命令。

    go run github.com/google/wire/cmd/wire
    

    wire 会扫描 wire.go,分析 InitializeUserHandler 函数,然后生成一个新文件 wire_gen.go

  3. 查看生成的代码 (wire_gen.go):

    // wire_gen.go
    package main
    
    import (
        "yourproject/handler"
        "yourproject/service"
        "yourproject/storage"
    )
    
    // InitializeUserHandler 的自动生成版本
    func InitializeUserHandler() (*handler.UserHandler, error) {
        memoryRepository := storage.NewMemoryRepository()
        userService := service.NewUserService(memoryRepository)
        userHandler := handler.NewUserHandler(userService)
        return userHandler, nil
    }
    

    看!这和我们手写的代码几乎一模一样。现在,我们的 main 函数可以变得极其简洁:

    // main.go (使用 wire)
    package main
    
    import (
        "log"
        "net/http"
    )
    
    func main() {
        userHandler, err := InitializeUserHandler() // 调用 wire 生成的函数
        if err != nil {
            log.Fatal(err)
        }
    
        http.HandleFunc("/register", userHandler.Register)
        log.Println("Server starting on :8080")
        log.Fatal(http.ListenAndServe(":8080", nil))
    }
    

实践练习

练习 1(基础):扩展服务 在上述示例中,为 UserService 增加一个获取用户信息的方法 GetUser(ctx context.Context, id string) (*storage.User, error),该方法调用 repo.GetByID。然后,为 UserHandler 增加对应的 HTTP 处理函数 GetUser(w http.ResponseWriter, r *http.Request)。最后,更新 wire.go 并重新生成代码,确保新的依赖链正确连接。

练习 2(中级):使用 Provider Set 当依赖增多时,可以将相关的 Provider 组织成集合。请将示例中 storage 包和 service 包的 Provider(NewMemoryRepository, NewUserService)分别封装到各自的 ProviderSet 中,例如:

// storage/provider.go
var ProviderSet = wire.NewSet(NewMemoryRepository)

然后修改 wire.go,使用这些 ProviderSet 代替单个函数。

练习 3(进阶):集成配置 假设 MemoryRepository 需要一个配置参数(如最大连接数)来初始化。请:

  1. 定义一个 Config 结构体。
  2. 修改 NewMemoryRepository 的签名以接收 *Config
  3. main 包中创建一个提供 *Config 的函数。
  4. 更新 wire.go 以注入配置,并重新生成代码。

常见错误

  1. 忘记实现接口wire.Build 中的 Provider 返回的具体类型,必须满足所有下游 Provider 对接口的需求。否则会得到一个类型错误的编译错误。
  2. 循环依赖:如果组件 A 依赖 B,同时 B 又依赖 A,wire 在编译时就会报错。这通常是设计问题,需要重新审视组件职责。
  3. 构建标签错误wire.go 文件必须带有 //go:build wireinject 或旧版的 // +build wireinject 标签。忘记添加会导致 go run wire 时无法找到注入器函数。
  4. wire.go 中编写业务逻辑wire.go 的注入器函数体应该只包含 wire.Build(...) 调用,返回零值。任何业务逻辑都应该放在由 wire 连接起来的那些组件中。
  5. 混淆生成的文件和手动修改的文件wire_gen.go 是自动生成的,任何时候都不要手动修改,它会在下次运行 wire 时被覆盖。

小结

  • 依赖注入 (DI) 是一种设计原则,通过外部提供依赖来创建松耦合、可测试的代码。
  • wire 是一个编译时依赖注入工具,通过代码生成实现零反射、高性能和编译期安全。
  • 核心工作流是:定义接口与实现 -> 编写注入器函数 (wire.Build) -> 运行 wire 生成代码 -> 在 main 中使用生成的初始化函数。
  • ProviderSet 是组织复杂依赖图的有效方式,能提升可读性和可维护性。
  • 依赖注入是迈向整洁架构可测试 Go 应用程序的基石。在下一课中,我们将看到如何将这些实践与分层架构相结合,构建出易于理解和变更的系统。

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「整洁架构实践」 以巩固所学知识。