Go 手动依赖注入

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

#type / concept #status / growing #tech / dev / backend #resource / go

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

结合场景再看三个关注点

  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 等是代码生成的手动 DI,不是运行时容器。

边界情况

  1. 依赖过多:构造函数参数爆炸 → 拆模块或引入小组配置 struct(仍显式)。
  2. 可选依赖:用函数选项或 nil 检查,并写清文档。
  3. 循环依赖:是设计味道,用事件/接口拆环,不要靠延迟 getter 糊弄。
  4. 接口放哪:优先使用方(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
}
  1. main 极瘦:os.Exit 处理 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/7/15