Go 手动依赖注入
在 main 或 wire 函数中自下而上构造依赖:接口入参、具体返回、生命周期归属,以及何时才需要 DI 框架。
[!info] 关联笔记
Go 手动依赖注入
这个概念为什么出现
服务里通常有多层对象:
Handler → Service → Repository → *sql.DB / Redis / 外部客户端
若每层 New 时自己去 sql.Open 或读全局变量,会出现:
- 测试无法替换依赖
- 生命周期混乱(谁
Close?) - 隐藏依赖,调用图不可读
依赖注入把“构造图”显式化:需要什么就通过参数传入。Go 社区默认偏好手动组装,而不是反射容器。
[!abstract] 一句话理解 在
main(或cmd组装函数)里自下而上New具体类型,向上通过接口注入;测试时换假实现。框架可选,不是起步条件。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:用户资料页查询——Handler → Service → Repo 手动组装
资料页要按用户 ID 显示昵称:
- Handler 收 HTTP/CLI 入参
- Service 编排用例
- Repo 读存储
若 UserService 内部自己 sql.Open 或读全局 DB,单测就换不掉存储,生命周期也含糊。
手动 DI:在 main(组装根)自下而上 New,Service 只认 UserRepo 接口。
package main
import (
"context"
"fmt"
)
// User 领域/读取模型(演示字段)。
type User struct{ ID, Name string }
// UserRepo 是 Service 需要的最小存储能力——由使用方定义接口。
type UserRepo interface {
Find(ctx context.Context, id string) (User, error)
}
// memRepo 模拟内存仓储(测试或演示用);生产可换成 sqlUserRepo。
type memRepo struct{ m map[string]User }
func (r *memRepo) Find(_ context.Context, id string) (User, error) {
u, ok := r.m[id]
if !ok {
return User{}, fmt.Errorf("not found")
}
return u, nil
}
// UserService 用例层:只依赖接口,不 import 具体 DB 驱动。
type UserService struct{ repo UserRepo }
// NewUserService 构造函数注入:依赖出现在签名上。
func NewUserService(repo UserRepo) *UserService {
return &UserService{repo: repo}
}
// DisplayName 资料页用例:按 id 取展示名。
func (s *UserService) DisplayName(ctx context.Context, id string) (string, error) {
u, err := s.repo.Find(ctx, id)
if err != nil {
return "", err
}
return u.Name, nil
}
// UserHandler 接入层:依赖具体 *UserService(或更小的接口,按团队习惯)。
type UserHandler struct{ svc *UserService }
func NewUserHandler(svc *UserService) *UserHandler {
return &UserHandler{svc: svc}
}
// ShowProfile 模拟 HTTP handler 内的一次查询(此处直接调 Service)。
func (h *UserHandler) ShowProfile(ctx context.Context, id string) (string, error) {
return h.svc.DisplayName(ctx, id)
}
func main() {
// 组装根:唯一知道全部具体类型的地方(cmd/server 同理)。
repo := &memRepo{m: map[string]User{"1": {ID: "1", Name: "Ada"}}}
svc := NewUserService(repo) // 传入接口,repo 具体类型在 main
h := NewUserHandler(svc)
// 模拟一次“打开资料页”。
name, err := h.ShowProfile(context.Background(), "1")
fmt.Println(name, err)
}
建议运行:
go run .
期望输出:
Ada <nil>
结合场景再看三个关注点
-
构造函数注入
NewUserService(repo UserRepo)让依赖可见、可测;测试传入假 repo 即可。 -
接受接口,返回结构体
Service 参数用小接口;返回*UserService具体类型,避免过早抽象返回值。 -
组装根只在 main/cmd
业务包不init连库;谁New谁清楚 Close 生命周期。
核心模型
1. 构造函数注入(首选)
func NewOrderService(repo OrderRepo, pay PaymentClient, log *slog.Logger) *OrderService
- 依赖出现在签名上,可读、可测
- 返回具体类型
*OrderService - 参数用接口描述行为需求
2. 接受接口,返回结构体
与 API 约定 一致:调用方/上层定义小接口,实现方返回具体类型。
3. 组装根(Composition Root)
通常只有 main / cmd/server:
- 加载配置
- 打开 DB/Redis(基础设施)
- 构造 repository
- 构造 service
- 构造 handler / router
- 运行服务器并负责关闭
业务包不应再偷偷全局 init 连库。
4. 生命周期
| 对象 | 谁创建 | 谁关闭 |
|---|---|---|
*sql.DB | main | main / shutdown |
| HTTP Server | main | graceful shutdown |
| 无状态 Service | main | 通常无需 |
| 请求级值 | middleware | 随请求结束 |
5. 与全局变量
包级 var DB *sql.DB 能跑,但:
- 测试并行污染
- 导入副作用
- 隐藏初始化顺序
优先显式参数;确需全局时至少提供 Set/Open 测试钩子。
设计动机
Go 编译快、接口小、样板可接受。手动 DI:
- 无反射魔法
- 依赖图在代码审查中可见
- 与 测试替身 天然契合
Google Wire 等是代码生成的手动 DI,不是运行时容器。
边界情况
- 依赖过多:构造函数参数爆炸 → 拆模块或引入小组配置 struct(仍显式)。
- 可选依赖:用函数选项或
nil检查,并写清文档。 - 循环依赖:是设计味道,用事件/接口拆环,不要靠延迟 getter 糊弄。
- 接口放哪:优先使用方(service 定义
UserRepo),实现在internal/repo。
常见误区
[!warning] 业务层
sql.Open应注入已配置的*sql.DB或仓储。
[!warning] 为每个具体类型生成 mock 框架 先抽真实需要的小接口,手写 fake 往往够用。
[!warning] 过早上全功能 DI 容器 中小型服务手动组装更清晰。
工程实践
func run(ctx context.Context) error {
cfg, err := config.Load()
if err != nil {
return err
}
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
users := postgres.NewUserRepo(db)
svc := app.NewUserService(users)
h := httpapi.NewUserHandler(svc)
srv := httpapi.NewServer(cfg.Addr, h)
return srv.Run(ctx) // 内部处理信号与 Shutdown
}
main极瘦:os.Exit处理run错误- 测试:
NewUserService(&fakeRepo{}) - 文档化关闭顺序:先停 HTTP,再关 worker,再关 DB
- 配置与密钥只在组装根解析
可验证实验
- 把 service 改为依赖接口后,用内存 repo 跑通单测。
- 故意在 repo 包 import handler 包,观察循环依赖。
- 对比:全局
DBvs 注入在并行go test下的表现。
本节总结
自测题
概念题
- 为什么说
main是 composition root? - Service 的构造函数参数更宜接口还是具体 repo 结构体?
代码推理题
NewHandler 内部调用 sql.Open。测试 handler 时有何困难?如何改?
工程思考题
服务有 12 个构造参数是否合理?如何重构而不引入反射容器?
参考答案
展开
- 只有此处知道全部具体实现并拥有进程级资源。
- 对测试与替换:接口;若无替换需求且同模块,具体类型也可,但跨层更常用接口。
代码:无法注入假 DB;改为接收UserService或接口。
工程:按子系统拆NewUserModule(db) (handlers, cleanup),或参数对象,保持显式。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Go Code Review Comments | Wiki | 接口与依赖风格 |
| Package names | 博客 | 包边界 |
| google/wire | 工具 | 生成式手动 DI(可选) |
笔记元信息
- 文件:
go-dependency-injection-manual.md - 所属阶段:服务工程
- 状态:已深化