Go-依赖注入

依赖注入(Dependency Injection,DI)是后端开发绕不开的核心概念之一。你可能听过它能"解耦""易测试""可扩展",也可能在 Go 里看到 samber/dowire 这类工具,但仍不确定:DI 到底是什么?它解决什么问题?什么时候该用容器?

这篇文章用 通俗比喻 + 代码对比 + 工程实践 把 DI 讲清楚,并给出 Go 的手动 DI 与 samber/do 容器示例。


1. 最通俗但准确的定义

依赖注入(DI)

一个组件需要用到的"依赖"(例如 Logger、Repo、DB、HTTP Client),不由它自己在内部创建,而是由外部把依赖"传进来"(注入)。

DI 的核心是**"依赖从外部来"**,而不是"必须用接口"或"必须用框架"。


2. DI、IoC、容器:三者关系别混了

很多人把这三个词混用,建议这样理解:

  • IoC(控制反转) :一种设计思想/原则
    • "对象怎么被创建、怎么被组装"的控制权,从业务代码反转到外部。
  • DI(依赖注入) :IoC 的一种常见实现方式
    • 通过构造函数/函数参数/Setter/字段,把依赖传入。
  • 容器(Container) :实现 DI/IoC 的工具
    • 帮你管理创建、生命周期、依赖解析、缓存等。

一句话总结:
IoC 是思想,DI 是手段,容器是工具。


3. 生活比喻:自己造装备 vs 别人给你发装备

把业务组件想象成"打怪的角色"(比如 UserService):

  • 不用 DI :你要自己挖矿、炼铁、造剑(在代码里 new 依赖)
    • 结果是耦合高,改装备要改角色代码。
  • 用 DI :装备由"外部系统"发给你(注入依赖)
    • 角色只负责用装备打怪,不负责造装备。

4. 不用 DI 的典型问题(先看反例)

下面是常见的"依赖写死在内部"的写法:UserService 直接创建日志和仓库。

写法 A:业务类自己创建依赖(高耦合)

go 复制代码
package main

import "fmt"

// ---- 具体实现:日志 ----
type StdLogger struct{}
func (l *StdLogger) Info(msg string) { fmt.Println("[INFO]", msg) }

// ---- 具体实现:仓库 ----
type InMemoryUserRepo struct{}
func (r *InMemoryUserRepo) FindNameByID(id int) string {
	if id == 1 { return "Alice" }
	return ""
}

// ---- 业务服务:内部"硬编码"依赖 ----
type UserService struct {
	log  *StdLogger
	repo *InMemoryUserRepo
}

func NewUserService() *UserService {
	return &UserService{
		log:  &StdLogger{},
		repo: &InMemoryUserRepo{},
	}
}

func (s *UserService) Greet(id int) string {
	s.log.Info("loading user")
	return "hello " + s.repo.FindNameByID(id)
}

func main() {
	svc := NewUserService()
	fmt.Println(svc.Greet(1))
}

这会带来什么问题?

  1. 强耦合UserService 绑死 StdLogger / InMemoryUserRepo
    • 想换成 ZapLogger 或 MySQL Repo?必须改 UserService
  2. 难测试 :很难注入 mock/stub
    • 单元测试不得不依赖真实实现,测试边界变大。
  3. 依赖链复杂时会失控 :Repo 又依赖 DB,DB 又依赖配置......NewUserService 变成"上帝函数"。

5. 手动 DI:最推荐的入门与 Go 常用实践

DI 的本质就是:把依赖作为参数传进去 。Go 里最常见、最清晰的方式是构造函数注入(或函数参数注入)。

写法 B:构造函数注入(手动 DI)

go 复制代码
package main

import "fmt"

// 1) 依赖用"抽象"表达(通常是接口),便于替换与测试
type Logger interface {
	Info(msg string)
}
type UserRepo interface {
	FindNameByID(id int) string
}

// 2) 具体实现
type StdLogger struct{}
func (l *StdLogger) Info(msg string) { fmt.Println("[INFO]", msg) }

type InMemoryUserRepo struct{}
func (r *InMemoryUserRepo) FindNameByID(id int) string {
	if id == 1 { return "Alice" }
	return ""
}

// 3) 业务服务:只声明依赖,不创建依赖
type UserService struct {
	log  Logger
	repo UserRepo
}

func NewUserService(log Logger, repo UserRepo) *UserService {
	return &UserService{log: log, repo: repo}
}

func (s *UserService) Greet(id int) string {
	s.log.Info("loading user")
	return "hello " + s.repo.FindNameByID(id)
}

func main() {
	// 4) 依赖在外部创建并注入(composition root)
	log := &StdLogger{}
	repo := &InMemoryUserRepo{}

	svc := NewUserService(log, repo)
	fmt.Println(svc.Greet(1))
}

这就是 DI 的核心收益

  • UserService 不再关心依赖怎么创建,只关心怎么使用。
  • 换实现时只改"组装处",业务代码不动。
  • 单测时可以注入 MockRepo/MockLogger。

重要澄清:DI 不要求你一定用接口。

但在需要替换实现、隔离测试时,用接口往往更合适。


6. 单元测试为什么会因为 DI 变简单?

没有 DI 时,你很难把 Repo 换成测试替身;有 DI 之后,测试可以这样写:

go 复制代码
type FakeRepo struct{}
func (r *FakeRepo) FindNameByID(id int) string { return "TestUser" }

type NopLogger struct{}
func (l *NopLogger) Info(msg string) {}

func ExampleUserService() {
	svc := NewUserService(&NopLogger{}, &FakeRepo{})
	_ = svc.Greet(123) // 不需要真实 DB、真实日志
}

测试关注点回到"业务逻辑是否正确",而不是"依赖环境能不能跑起来"。


7. 什么时候需要 DI 容器?(以及它的代价)

手动 DI 很清晰,但当项目变大时,你可能会遇到:

  • 依赖链很深:A 依赖 B,B 依赖 C、D......
  • 生命周期管理复杂:单例/每请求/可关闭资源(DB、MQ)
  • 组件可插拔:不同环境/不同部署组合不同实现

这时可以考虑容器或代码生成工具来做"组装"。

但容器也有代价:

  • 依赖变"隐式":不看构造函数参数,可能不知道一个服务需要什么
  • 运行时才报错:缺依赖、循环依赖可能启动才炸
  • 调试链变长:追对象怎么来的更费劲
  • 容器用不好会变成 Service Locator(到处 Invoke/Resolve),反而更糟

Go 工程里常见建议是:

  • 默认手动 DI(显式传参)
  • 依赖很复杂时 再考虑:
    • wire(编译期生成装配代码)
    • 或容器(运行时解析)如 samber/do

8. samber/do:用容器做 DI(运行时解析)

samber/do 的核心思路是:

  1. 建一个 Injector 容器
  2. Provide 注册"如何构建某个依赖"
  3. Invoke 获取实例(容器会解析并构建依赖链)

下面给出一个完整、可读的示例(风格偏"集中组装"):

go 复制代码
package main

import (
	"fmt"

	"github.com/samber/do"
)

// ---- 抽象 ----
type Logger interface{ Info(string) }
type UserRepo interface{ FindNameByID(int) string }

// ---- 实现 ----
type StdLogger struct{}
func (l *StdLogger) Info(msg string) { fmt.Println("[INFO]", msg) }

type InMemoryUserRepo struct{}
func (r *InMemoryUserRepo) FindNameByID(id int) string {
	if id == 1 { return "Alice" }
	return ""
}

// ---- 业务服务 ----
type UserService struct {
	log  Logger
	repo UserRepo
}

func (s *UserService) Greet(id int) string {
	s.log.Info("loading user")
	return "hello " + s.repo.FindNameByID(id)
}

func main() {
	inj := do.New()

	// 1) 注册 Logger
	do.Provide(inj, func(i *do.Injector) (Logger, error) {
		return &StdLogger{}, nil
	})

	// 2) 注册 UserRepo
	do.Provide(inj, func(i *do.Injector) (UserRepo, error) {
		return &InMemoryUserRepo{}, nil
	})

	// 3) 注册 UserService(从容器拿依赖并组装)
	do.Provide(inj, func(i *do.Injector) (*UserService, error) {
		log := do.MustInvoke[Logger](i)
		repo := do.MustInvoke[UserRepo](i)
		return &UserService{log: log, repo: repo}, nil
	})

	// 4) 获取并使用
	svc := do.MustInvoke[*UserService](inj)
	fmt.Println(svc.Greet(1))
}

关键工程建议:把 "Provide/组装"集中在 Composition Root

composition root 通常就是 main()cmd/xxx/main.go、初始化层。

尽量避免在业务逻辑里到处写:

go 复制代码
repo := do.MustInvoke[UserRepo](inj)

这会让业务代码"反向依赖容器",逐渐滑向 Service Locator 模式,降低可测试性和可读性。


9. 手动 DI vs 容器 DI vs wire:怎么选(实用决策)

优先级(经验排序)

  1. 手动 DI(构造函数/参数):默认选择,最清晰、最 Go
  2. wire(生成装配代码):依赖复杂但你仍希望"显式代码、编译期失败"
  3. 容器(如 samber/do:需要运行时插件化/动态组合/统一生命周期管理

你可能适合上容器的信号

  • 依赖树很深,手动组装维护成本明显上升
  • 需要按环境动态替换实现(且组合多)
  • 需要统一资源生命周期、关闭顺序、单例缓存策略

你可能不该上容器的信号

  • 项目规模不大,依赖链很浅
  • 团队更看重显式与可读性(Go 常见)
  • 希望"问题尽量编译期暴露"

10. 总结(记住这 4 句话就够了)

  1. DI 的本质:组件不自己创建依赖,依赖由外部注入。
  2. DI 的价值:解耦、可替换、易测试、可维护。
  3. 容器不是 DI 的必要条件:手动传参就是 DI,而且在 Go 里常常更好。
  4. 用容器要克制 :把装配集中在 composition root,避免业务代码到处 Invoke 变 Service Locator。
相关推荐
斯瓦辛武2 小时前
webchat中间件的搭建过程
后端
Penge6662 小时前
Go 泛型:一行代码提升依赖注入的类型安全
后端
凌云拓界2 小时前
TypeWell全攻略(四):AI键位分析,让数据开口说话
前端·人工智能·后端·python·ai·交互
kyrie学java2 小时前
SpringBoot搭建项目调试与问题解决
java·spring boot·后端
SimonKing2 小时前
多数据源:CSV、内存对象可以通过SQL查询,甚至联查,你敢信!
java·后端·程序员
Kapaseker3 小时前
Python 正在遭遇人气下滑
后端·python
自在极意功。3 小时前
Spring Boot 自动配置原理基本理解
java·spring boot·后端·自动配置原理
风象南3 小时前
用 OpenClaw + 飞书,快速搭建 5 个可协作的 AI 助理团队
后端
ding_zhikai3 小时前
【Web应用开发笔记】Django笔记3:模版的用法-实现一个简单的网页
笔记·后端·python·django