← [ BACK_TO_NOTES ]

Go 手动依赖注入

在 main 或 wire 函数中自下而上构造依赖:接口入参、具体返回、生命周期归属,以及何时才需要 DI 框架。

#concept #growing #backend #go

[!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>

结合场景再看三个关注点

  1. 构造函数注入
    NewUserService(repo UserRepo) 让依赖可见、可测;测试传入假 repo 即可。

  2. 接受接口,返回结构体
    Service 参数用小接口;返回 *UserService 具体类型,避免过早抽象返回值。

  3. 组装根只在 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:

  1. 加载配置
  2. 打开 DB/Redis(基础设施)
  3. 构造 repository
  4. 构造 service
  5. 构造 handler / router
  6. 运行服务器并负责关闭

业务包不应再偷偷全局 init 连库。

4. 生命周期

对象谁创建谁关闭
*sql.DBmainmain / shutdown
HTTP Servermaingraceful shutdown
无状态 Servicemain通常无需
请求级值middleware随请求结束

5. 与全局变量

包级 var DB *sql.DB 能跑,但:

  • 测试并行污染
  • 导入副作用
  • 隐藏初始化顺序

优先显式参数。给全局变量增加 Set/Open 测试钩子并不能消除数据竞争或测试间污染;确需进程级共享时,应明确同步、生命周期与测试隔离。

设计动机

Go 编译快、接口小、样板可接受。手动 DI:

  • 无反射魔法
  • 依赖图在代码审查中可见
  • 与 测试替身 天然契合

Google Wire 是生成组装代码的工具,不是运行时容器。其官方 README 已声明不再维护(2026-10-06 核对);旧项目可理解已有生成代码,新选型不能把它当作仍受维护的默认方案。

边界情况

  1. 依赖过多:构造函数参数爆炸 → 拆模块或引入小组配置 struct(仍显式)。
  2. 依赖有效性:本例构造函数要求非空且可用的实现。接口装入 nil 指针后可能不等于 nil,单独 repo == nil 不能验证所有 typed nil;可选性应由清晰的构造契约表达。
  3. 循环依赖:是设计味道,用事件/接口拆环,不要靠延迟 getter 糊弄。
  4. 接口放哪:优先使用方(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) // 应遵守上面的停止、排空及等待契约
}
  1. main 在 run 返回之后处理退出码;os.Exit 不执行 defer,资源清理应留在 run 内完成。
  2. 测试:NewUserService(&fakeRepo{})
  3. 按依赖关系停止接收新工作并等待在途任务,再关闭底层资源;固定的 HTTP/worker/DB 顺序并不适合所有拓扑。
  4. 配置与密钥只在组装根解析

可验证实验

  1. 把 service 改为依赖接口后,用内存 repo 跑通单测。
  2. 故意在 repo 包 import handler 包,观察循环依赖。
  3. 对比:全局 DB vs 注入在并行 go test 下的表现。

本节总结

  • 本质:显式构造依赖图。
  • 关键规则:构造函数注入、组装根唯一、接口隔离、生命周期归 main。
  • 下一步:三层分层 与 优雅关闭。

自测题

概念题

  1. 为什么说 main 是 composition root?
  2. Service 的构造函数参数更宜接口还是具体 repo 结构体?

代码推理题

NewHandler 内部调用 sql.Open。测试 handler 时有何困难?如何改?

工程思考题

服务有 12 个构造参数是否合理?如何重构而不引入反射容器?

参考答案

展开
  1. 只有此处知道全部具体实现并拥有进程级资源。
  2. 对测试与替换:接口;若无替换需求且同模块,具体类型也可,但跨层更常用接口。
    代码:无法注入假 DB;改为接收 UserService 或接口。
    工程:按子系统拆 NewUserModule(db) (handlers, cleanup),或参数对象,保持显式。

延伸阅读与资料来源

资料类型支撑
Go Code Review CommentsWiki接口与依赖风格
Package names博客包边界
google/wire工具生成式手动 DI(可选)

笔记元信息

  • 文件:go-dependency-injection-manual.md
  • 所属阶段:服务工程
  • 状态:已深化
创建于 2026/6/25 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本