--- tags: [go, dependency-injection, di, architecture, testing, refactoring] create time: 2026-04-29 15:30 --- # Go 依赖注入(Dependency Injection) ## 概述 系统梳理 Go 语言中依赖注入的实践模式、选型决策和常见陷阱。Go 的 DI 哲学与 Java Spring 等框架截然不同——它推崇显式构造器注入,不依赖反射、IoC 容器或运行时魔法。 ## 一、为什么 Go 不需要 IoC 容器? > [!question] 思考:Spring 的 BeanFactory 在 Go 里是什么? > > 在 Go 中,**构造函数就是 IoC 容器**。调用者一眼就能看到类型需要哪些依赖,不需要查看注册表、配置文件或注解。这就是 Go **"显式优于隐式"** 的设计哲学。 Java/Spring 的典型风格 vs Go 风格对比: ```java // ❌ Java Spring —— 隐式绑定,运行时查找 @Service public class OrderService { @Autowired private OrderRepository repository; // 谁也不知道从哪里注入来的 } ``` ```go // ✅ Go —— 显式构造,编译期检查 type OrderService struct { repo OrderRepository // 结构体字段只是声明 } func NewOrderService(repo OrderRepository) *OrderService { return &OrderService{repo: repo} // 依赖关系写得一清二楚 } ``` | 维度 | Spring IoC 容器 | Go 手动注入 | |------|----------------|------------| | **依赖可见性** | 通过注解 + 容器配置 | 构造函数签名直接暴露 | | **编译时检查** | 部分(缺 Bean 可能运行时才报错) | 全部(少传参数直接编译失败) | | **调试难度** | 启动失败时 stack trace 深不见底 | 普通函数调用,panic 信息直观 | | **性能开销** | 启动时大量反射初始化 | 零额外开销 | | **测试友好度** | 需 MockBean/@SpringBootTest 配合 | 直接传 mock 构造即可 | ## 二、构造器注入:DI 的最基本形式 核心原则:**结构体不自己创建依赖,而是通过构造函数接收**。 ### 2.1 反模式 vs 推荐模式 ```go // ❌ 反模式:服务内部自行创建依赖(紧耦合,不可替换) type OrderService struct { db *sql.DB // 硬编码的具体类型 } func NewOrderService() *OrderService { conn, _ := sql.Open("postgres", dsn) // 隐藏了副作用和错误处理 return &OrderService{db: conn} } ``` 问题: 1. `dsn` 从哪来?对调用方来说是隐形依赖。 2. 单元测试必须启动真实数据库,无法 mock。 3. 想换成 Redis 做缓存?需要改源码。 ```go // ✅ 推荐:外部注入依赖,依赖抽象化 type OrderService struct { repo OrderRepository logger *slog.Logger cache Cache } func NewOrderService( repo OrderRepository, logger *slog.Logger, cache Cache, ) *OrderService { return &OrderService{repo: repo, logger: logger, cache: cache} } ``` 三个好处: - **可测试**:传入 mock Repo,无需启动 DB。 - **可见**:构造函数签名 = 完整的依赖清单,IDE 自动补全提示。 - **灵活**:同一 service 可按场景注入不同组合。 ### 2.2 为什么用接口而非具体类型? ```go // Service 依赖的是接口,不是具体实现 type OrderRepository interface { ListByUserID(ctx context.Context, userID int64) ([]*Order, error) Create(ctx context.Context, o *Order) error } ``` 这实现了 **[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)**: - 高层模块(service)不依赖低层模块(repository 的具体实现)。 - 两者都依赖于**抽象**(interface)。 - 切换实现只需改 Composition Root 一行代码,业务逻辑零改动。 > [!tip] YAGNI 接口原则 > > 不要一开始就给所有 struct 加接口。**只在必要时定义接口**: > > | 情况 | 是否定义接口 | > |------|-------------| > | 需要跨包引用 | ✅ 必须 | > | 需要用 mock 做单元测试 | ✅ 必须 | > | 只在一个包内使用,无独立测试 | ❌ 不需要 | ## 三、可选依赖:Options 模式 当依赖增多到 4~5 个以上时,构造函数参数变得臃肿。Go 社区的标准解法是**函数选项模式**: ```go type UserService struct { repo UserRepository logger *slog.Logger cache Cache enableLog bool maxRetries int } // Option 是修改 UserService 字段的函数 type Option func(*UserService) func WithLogger(logger *slog.Logger) Option { return func(s *UserService) { s.logger = logger } } func WithCache(cache Cache) Option { return func(s *UserService) { s.cache = cache } } func WithMaxRetries(n int) Option { return func(s *UserService) { s.maxRetries = n } } // 必选 + 可选分离 func NewUserService(repo UserRepository, opts ...Option) *UserService { s := &UserService{ repo: repo, enableLog: true, // 合理默认值 maxRetries: 3, } for _, opt := range opts { opt(s) } return s } // 调用点清晰可读 svc := NewUserService(repo, WithLogger(logger), WithCache(newRedisCache()), ) ``` > [!info] Options 模式的权衡 > > - **优点**:向后兼容(新增选项不影响旧调用方),调用点像声明式 DSL。 > - **缺点**:每个选项多写一个函数;超过 6~8 个选项时需考虑拆分 service。 ## 四、Composition Root:在哪里组装依赖? **Composition Root**(组合根目录)是 DI 的核心概念——在整个应用入口处将依赖组装在一起。Go 项目中它就是 `cmd/main.go`: ```go package main func main() { cfg := loadConfig() // ── 基础设施层 ── db := initPostgres(cfg.DSN) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) redisClient := initRedis(cfg.RedisURL) // ── 数据访问层(接口 ←→ 实现对接)── userRepo := repository.NewPostgresUserRepo(db) orderRepo := repository.NewPostgresOrderRepo(db) // ── 业务逻辑层 ── userSvc := service.NewUserService(userRepo, service.WithLogger(logger), ) orderSvc := service.NewOrderService(orderRepo, userRepo, service.WithLogger(logger), service.WithCache(redisClient), ) // ── 表现层 ── router := handler.NewRouter(userSvc, orderSvc) log.Printf("server starting on :%s", cfg.Port) http.ListenAndServe(":"+cfg.Port, router) } ``` > [!warning] 常见陷阱 > > - **不要在 service 内部 new 依赖**:一旦某个地方绕过了构造器,整个 DI 链条就断了。 > - **不要全局变量存依赖**:`var db *sql.DB` 会导致包级状态污染,并发问题和测试隔离问题接踵而来。 > - **不要在 service 之间循环调用**:orderSvc 注入了 userRepo,那 userSvc 就不应该再注入 orderSvc。 > - **不要为了 DI 而 DI**:工具函数包不需要接口+注入——过度设计也是反模式。 ## 五、Wire:手动 DI vs 代码生成 Go 生态中有 fx(Uber)、wire 等 DI 工具,但**社区普遍推荐小中型项目手动组装**。 | 维度 | 手动组装 | wire(编译时代码生成) | fx(运行时间单解析) | |------|---------|----------------------|-------------------| | 学习成本 | 零 | 中等(理解 build tags + 注释标注) | 高(App 生命周期 + 回调规则) | | 调试难度 | 低 | 低(生成的代码即普通 Go 代码) | 中高(运行时 panic,堆栈深) | | 启动速度 | 无开销 | 无开销 | 有反射开销 | | 适用规模 | < 50 个组件 | 50+ 组件,频繁变更依赖图 | 较少推荐,社区认可度低于 wire | ### 5.1 Wire 的工作原理 Wire 不是运行时框架,它在**编译期生成初始化代码**,生成的文件和手写的没有任何区别: ```bash # 安装 go install github.com/google/wire/cmd/wire@latest ``` 编写 injector(注意 `//go:build wireinject` build tag): ```go // go:build wireinject package main // InitializeApp 组装完整的 App 依赖图 func InitializeApp(cfg Config) (*App, error) { wire.Build( initPostgres, initRedis, slog.New, repository.NewPostgresUserRepo, repository.NewPostgresOrderRepo, service.NewUserService, service.NewOrderService, handler.NewRouter, App.New, ) return nil, nil } ``` 运行 wire: ```bash $ wire ./cmd/app # → 生成 cmd/app/inject.wire_gen.go ``` 生成的文件大致是: ```go // Code generated by wire; DO NOT EDIT. package main func InitializeApp(cfg Config) (*App, error) { db := initPostgres(cfg.DSN) redisClient := initRedis(cfg.RedisURL) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) userRepo := repository.NewPostgresUserRepo(db) orderRepo := repository.NewPostgresOrderRepo(db) userSvc := service.NewUserService(userRepo, nil /* no logger option */) orderSvc := service.NewOrderService(orderRepo, userRepo, nil, nil) router := handler.NewRouter(userSvc, orderSvc) app := NewApp(router) return app, nil } ``` > [!summary] Wire 的使用时机 > > - 依赖图 **< 20 个组件** → 手写,不需要 wire。 > - 依赖图 **20 ~ 50 个组件** → 手写 + options 模式完全够用。 > - 依赖图 **> 50 个组件** 且团队多人协作 → 考虑 wire 自动生成。 > - 不想引入任何额外工具链 → 手写,永远是最安全的选择。 ## 六、DI 在测试中的价值 DI 的最大受益者是**单元测试**——mock 替代真实依赖,测试变成快速、确定性的纯内存操作: ```go // ── Mock 实现 ── type mockUserRepo struct { mu sync.Mutex users map[int64]*User } func (m *mockUserRepo) ByID(ctx context.Context, id int64) (*User, error) { m.mu.Lock() defer m.mu.Unlock() u, ok := m.users[id] if !ok { return nil, ErrNotFound } // 返回副本,防止测试间相互篡改 cp := *u return &cp, nil } func (m *mockUserRepo) Save(ctx context.Context, u *User) error { m.mu.Lock() defer m.mu.Unlock() m.users[u.ID] = u return nil } // ── 单元测试:毫秒级执行,不依赖外部环境 ── func TestCreateOrder(t *testing.T) { repo := &mockUserRepo{ users: map[int64]*User{1: {ID: 1, Name: "alice"}}, } svc := NewOrderService(repo) order, err := svc.CreateOrder(context.Background(), 1, "widget", 2) require.NoError(t, err) assert.Equal(t, "alice", order.OwnerName) assert.Equal(t, 2, order.Quantity) } ``` > [!success] 最佳实践 > > - **单元测试**:全部用 mock,确保快速(ms 级)、确定性、CI 稳定。 > - **集成测试**:用真实 DB + [`testcontainers-go`](https://github.com/testcontainers/testcontainers-go),覆盖端到端链路。 > - **不要混用**:同一个包的测试要么全 mock,要么全真实——混合会带来难以复现的 flaky test。 ## 七、DI 在避免循环依赖中的作用 依赖注入不仅是装配技巧,还是解决**循环依赖**的核心手段(详见 [[Go 工程模块化]] 第四节): ``` handler → service → repository → model → handler ❌ ``` 传统写法中,`model` 被多层共用导致循环 import。通过 DI 可以打破: ```go // 1. domain 层只定义接口 type UserRepository interface { Save(ctx context.Context, u *User) error } // 2. service 依赖接口而非具体 repo type UserService struct { repo UserRepository } // 3. infrastructure 层实现接口 type PostgresUserRepo struct { db *sqlx.DB } // 4. main.go 中桥接接口与实现 // (没有任何一层产生循环 import) userRepo := &PostgresUserRepo{db: db} userSvc := NewUserService(userRepo) ``` ## 关联笔记 - [[Go 工程模块化]]