Go 手动依赖注入
在 main 或 wire 函数中自下而上构造依赖:接口入参、具体返回、生命周期归属,以及何时才需要 DI 框架。
[!info] related notes
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 要求调用方传入有效 repo;本例不检查接口中的 typed nil。
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)
}
将完整示例保存为新模块的 main.go,先 go mod init example.com/manual-di,再运行。它只模拟查询调用,不启动 HTTP;memRepo 在组装后只读,若增加写入需另行处理并发。
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 是生成组装代码的工具,不是运行时容器。其官方 README 已声明不再维护(2026-10-06 核对);旧项目可理解已有生成代码,新选型不能把它当作仍受维护的默认方案。
边界情况
- 依赖过多:构造函数参数爆炸 → 拆模块或引入小组配置 struct(仍显式)。
- 依赖有效性:本例构造函数要求非空且可用的实现。接口装入 nil 指针后可能不等于 nil,单独 repo == nil 不能验证所有 typed nil;可选性应由清晰的构造契约表达。
- 循环依赖:是设计味道,用事件/接口拆环,不要靠延迟 getter 糊弄。
- 接口放哪:优先使用方(service 定义
UserRepo),实现在internal/repo。
常见误区
[!warning] 业务层
sql.Open应注入已配置的*sql.DB或仓储。
[!warning] 为每个具体类型生成 mock 框架 先抽真实需要的小接口,手写 fake 往往够用。
[!warning] 过早上全功能 DI 容器 中小型服务手动组装更清晰。
工程实践
下面是接入已有项目的结构片段,config/postgres/app/httpapi 都是应用包,并非可直接运行的标准库 API。使用 sql.Open(“pgx”, …) 还需注册 pgx stdlib 驱动。假设调用方传入取消上下文,srv.Run 必须在返回前停止接流量并等待所有仍使用 DB 的任务结束。
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() // 后续初始化失败时也回收本函数创建的连接池
// sql.Open 通常只创建连接池句柄;可达性检查需显式执行。
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err = db.PingContext(pingCtx)
cancel()
if err != nil {
return err
}
users := postgres.NewUserRepo(db)
svc := app.NewUserService(users)
h := httpapi.NewUserHandler(svc)
srv := httpapi.NewServer(cfg.Addr, h)
return srv.Run(ctx) // 应遵守上面的停止、排空及等待契约
}
- main 在 run 返回之后处理退出码;os.Exit 不执行 defer,资源清理应留在 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 - 所属阶段:服务工程
- 状态:已深化