什么是依赖注入?
想象一个需要从数据库获取用户的Web处理器。没有依赖注入时,代码可能是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
type UserService struct{}
func (us *UserService) GetUser(id int) string {
// 假装我们从数据库获取用户
return "user"
}
type Handler struct {
userService UserService
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := h.userService.GetUser(1)
fmt.Fprintln(w, user)
}
|
这里,Handler与UserService紧密耦合。我们无法在测试中轻松替换UserService为模拟对象,也无法用不同的实现替换它。
使用依赖注入,我们通过构造函数将依赖项传递到结构体中:
1
2
3
4
5
6
7
8
9
10
11
|
type UserService interface {
GetUser(id int) string
}
type Handler struct {
userService UserService
}
func NewHandler(us UserService) *Handler {
return &Handler{userService: us}
}
|
现在Handler不关心UserService是如何创建的。这个决定留给调用代码(main.go或测试)。这就是依赖注入的本质:注入组件需要的东西,而不是让它在内部创建。
手动DI在Go中的实现
仓库层
在堆栈的底部,我们定义UserRepository:
1
2
3
4
5
6
7
8
9
10
11
12
|
type UserRepository struct {
// 想象这个结构体持有数据库客户端
}
func NewUserRepository() *UserRepository {
return &UserRepository{}
}
func (r *UserRepository) FindUser(id int) string {
// 在实际应用中,这会查询数据库
return fmt.Sprintf("user-%d", id)
}
|
服务层
在仓库之上,我们添加使用它的服务:
1
2
3
4
5
6
7
8
9
10
11
12
|
type UserService struct {
repo *UserRepository
}
func NewUserService(r *UserRepository) *UserService {
return &UserService{repo: r}
}
func (s *UserService) GetUser(id int) string {
// 在这里添加一些业务逻辑
return s.repo.FindUser(id)
}
|
处理器层
最后,在顶部添加Web处理器:
1
2
3
4
5
6
7
8
9
10
11
12
|
type Handler struct {
service *UserService
}
func NewHandler(s *UserService) *Handler {
return &Handler{service: s}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := h.service.GetUser(1)
fmt.Fprintln(w, user)
}
|
在main.go中连接所有组件
1
2
3
4
5
6
7
8
|
func main() {
repo := NewUserRepository() // 最底层
service := NewUserService(repo) // 依赖于repo
handler := NewHandler(service) // 依赖于service
http.Handle("/user", handler)
http.ListenAndServe(":8080", nil)
}
|
快速测试示例
为了看到手动DI的好处,让我们写一个快速测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
type FakeUserRepository struct{}
func (f *FakeUserRepository) FindUser(id int) string {
return "fake-user"
}
func TestUserService(t *testing.T) {
fakeRepo := &FakeUserRepository{}
service := NewUserService(fakeRepo)
got := service.GetUser(1)
want := "fake-user"
if got != want {
t.Errorf("got %s, want %s", got, want)
}
}
|
当DI变得困难时
考虑一个稍大的项目,有多个服务:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
func main() {
userRepo := NewUserRepository()
userService := NewUserService(userRepo)
authService := NewAuthService(userService)
smtpClient := NewSMTPClient("smtp.example.com")
emailService := NewEmailService(smtpClient)
smsService := NewSMSService()
notificationService := NewNotificationService(emailService, smsService)
handler := NewHandler(authService, notificationService)
http.Handle("/signup", handler)
http.ListenAndServe(":8080", nil)
}
|
这个例子已经冗长且难以阅读,即使应用程序不是很大。
Go DI库和工具
Google Wire(编译时DI)
Google Wire是一个编译时代码生成工具:
1
2
3
4
5
6
7
8
9
10
|
import "github.com/google/wire"
// 提供者集合
var Set = wire.NewSet(NewUserRepository, NewUserService, NewHandler)
// 注入器函数
func InitializeHandler() *Handler {
wire.Build(Set) // 在这里生成代码来连接依赖项
return nil
}
|
Uber Dig(运行时DI)
Uber Dig是一个运行时依赖注入容器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import "go.uber.org/dig"
func main() {
c := dig.New() // 创建新容器
// 向容器提供构造函数
c.Provide(NewUserRepository)
c.Provide(NewUserService)
c.Provide(NewHandler)
// 调用函数,让Dig解析依赖项
err := c.Invoke(func(h *Handler) {
http.Handle("/user", h)
})
if err != nil {
log.Fatal(err)
}
http.ListenAndServe(":8080", nil)
}
|
Uber Fx(DI + 应用生命周期)
Uber Fx基于Dig构建,并添加了应用生命周期管理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import "go.uber.org/fx"
func registerRoutes(lc fx.Lifecycle, handler *Handler) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
http.Handle("/user", handler)
go http.ListenAndServe(":8080", nil)
return nil
},
OnStop: func(ctx context.Context) error {
log.Println("shutting down server")
return nil
},
})
}
func main() {
app := fx.New(
fx.Provide(NewUserRepository, NewUserService, NewHandler),
fx.Invoke(registerRoutes),
)
app.Run() // 启动应用并管理生命周期钩子
}
|
最佳实践和要点
偏好显式依赖
Go中最重要的原则是清晰度:
- 通过构造函数传递依赖项,而不是隐藏的全局变量
- 在测试或交换实现时使用接口抽象行为
- 避免魔法 - 读者应该看到事物是如何连接的
从简单开始(手动DI)
对于大多数Go项目,手动DI就足够了:
- 在中小型服务中,在
main.go中手动连接很少成为瓶颈
- 显式连接兼作文档:你可以瞥一眼
main.go看看应用是如何组装的
- 过早添加框架可能增加复杂性而没有明显的好处
使用框架来管理复杂性
当你的main.go变成数百行样板代码时,考虑使用DI工具:
- Wire:如果你想要编译时安全性和显式生成的代码
- Dig:如果你想要运行时灵活性和最小设置
- Fx:如果你想要DI和应用生命周期管理
将连接保持在边缘
常见的实践是将连接与业务逻辑分开:
- 业务逻辑不应关心依赖项是如何构建的
- 连接应发生在应用入口点(
main.go或initApp()函数)
- 这种分离保持核心代码解耦和可测试
偏好接口以实现可测试性
依赖注入在测试时表现出色。要充分利用它,依赖接口而不是具体类型:
1
2
3
4
5
6
7
|
type UserRepository interface {
FindUser(id int) string
}
type UserService struct {
repo UserRepository
}
|
在生产中,你可以注入真实的DBUserRepository。在测试中,你可以注入FakeUserRepository。这使得测试快速、隔离且容易。
结论
Go中的依赖注入不需要神秘或复杂。其核心只是将依赖项传递到代码中,而不是在内部创建它们。这种设计上的小转变使你的应用更易于测试、更加模块化和更易维护。
我们看到了三种主要方法:
- 手动DI:Go中的惯用基线。显式、清晰,适合大多数项目
- 编译时工具如Wire:减少样板代码,同时保持连接显式
- 运行时框架如Dig和Fx:对于需要灵活性和生命周期管理的大型应用很强大
没有单一的"正确"选择。最佳方法取决于项目的大小和复杂性、团队的偏好,以及你愿意手动管理多少连接。