samber/do: Go 泛型 DI

发布时间: 更新时间: 总字数:2707 阅读时间:6m 作者:IP:上海 网址

github.com/samber/do 是 Go 语言生态中一个非常受欢迎的依赖注入(Dependency Injection, 简称 DI)工具包。它基于 Go 1.18+ 引入的**泛型(Generics)**特性构建,旨在为 Go 开发者提供一个类型安全、轻量级且功能丰富的依赖注入解决方案。

核心优势

设计目标是作为 Uber 的 uber/dig 或 Google 的 google/wire 等经典 DI 框架的现代替代品。

  • 类型安全(Type-safe):得益于 Go 1.18+ 的泛型,samber/do 摒弃了传统的反射(Reflection)机制,所有的服务注册和提取在编译期就能获得更好的类型提示,运行时也更加安全高效。
  • 无代码生成(No code generation):与 google/wire 不同,它不需要在构建前执行代码生成步骤,保持了开发流程的简洁。
  • 零外部依赖:除了 Go 标准库之外,没有任何第三方依赖,非常轻量。

主要功能特性

根据其官方文档,samber/do 提供了非常全面的依赖注入容器能力:

  • 服务注册(Service Registration)
    • 支持按类型(Type)或按名称(Name)注册。
    • 支持包级别的批量注册。
  • 服务调用/加载策略(Service Invocation)
    • Eager loading(预加载/饿汉式):容器初始化时即实例化服务。
    • Lazy loading(懒加载):默认方式,当服务第一次被请求时才进行实例化。
    • Transient loading(瞬态加载):每次请求该服务时都会创建一个新的实例。
  • 服务别名与接口绑定(Service Aliasing)
    • 非常方便地将具体的结构体(Struct)实现绑定到接口(Interface)上,实现面向接口编程。
  • 生命周期管理(Service Lifecycle)
    • 健康检查(Health check):可以单独或全局检查已注册服务的健康状态。
    • 优雅关闭(Graceful shutdown):支持按依赖的反向顺序(依赖感知的并行关闭)安全地卸载和关闭服务,这在关闭数据库连接等场景下非常有用。
  • 作用域与模块化(Scope Tree)
    • 支持创建子作用域(模块树),可以控制服务的可见性和依赖分组。
  • 调试与可视化(Debugging & Introspection)
    • 内置了依赖图解析和可视化功能,不仅可以输出依赖关系树,甚至还提供了 Web UI 和 HTTP 中间件(支持 Gin, Fiber, Echo, Chi 等常见框架)来直接查看依赖状态。

作者与生态环境

该库的作者是 Samuel Berthe (samber),他在 Go 社区非常活跃。如果提及其另外几个基于 Go 泛型的明星库,你可能就不会陌生:

  • samber/lo:Go 语言版的 Lodash(极为火爆的泛型工具库)。
  • samber/mo:Go 语言的 Monads 库(Option, Result, Either 等)。

目前 samber/do 已经迭代到了 v2 版本(遵循严格的语义化版本控制),API 更加成熟稳定。同时,作者还提供了一系列脚手架模板(如 do-template-api, do-template-worker, do-template-cli),帮助开发者快速上手使用 DI 搭建 API、后台任务或命令行工具。

适用场景

如果你正在使用 Go 1.18 及以上版本开发一个中大型项目,模块较多,对象之间的依赖关系复杂(例如 Controller 依赖 Service,Service 依赖 Repository 和 Cache),并且你希望:

  1. 摆脱手动组装 A(B(C(), D())) 的繁琐过程。
  2. 避免引入基于反射的性能损耗和类型不安全问题。
  3. 不想使用繁重的代码生成工具。

那么 github.com/samber/do 将是目前 Go 生态中最优雅、最符合现代 Go 泛型风格的依赖注入框架选择。

github.com/samber/do 的核心思想是围绕 Injector (依赖注入容器)Provider (服务提供者/构造函数)Invoke (提取服务) 这三个概念展开的。

由于目前推荐使用 v2 版本,下面的示例均基于 github.com/samber/do/v2 编写。

安装

bash
go get github.com/samber/do/v2

示例 1:基础使用与依赖嵌套 (最常见场景)

这个例子展示了如何定义组件、注册组件以及它们之间如何相互依赖。假设我们有一个 Service 依赖于一个 Repository

go
package main

import (
	`fmt`
	`github.com/samber/do/v2`
)

// ==========================================
// 1. 定义底层依赖: Repository
// ==========================================
type DBRepository struct{}

func (repo *DBRepository) GetUser() string {
	return `Alice`
}

// 构造函数:必须符合规范 func(i do.Injector) (T, error)
func NewDBRepository(i do.Injector) (*DBRepository, error) {
	fmt.Println(`--> 初始化 DBRepository`)
	return &DBRepository{}, nil
}

// ==========================================
// 2. 定义上层服务: Service (依赖 Repository)
// ==========================================
type UserService struct {
	repo *DBRepository
}

func (s *UserService) PrintUser() {
	fmt.Println(`User is:`, s.repo.GetUser())
}

// 构造函数:从 Injector 中取出所需依赖
func NewUserService(i do.Injector) (*UserService, error) {
	fmt.Println(`--> 初始化 UserService`)

	// 使用 do.MustInvoke 获取已注册的依赖
	repo := do.MustInvoke[*DBRepository](i)

	return &UserService{repo: repo}, nil
}

// ==========================================
// 3. 主函数
// ==========================================
func main() {
	// 创建一个新的 DI 容器
	injector := do.New()

	// 注册服务。
	// 注意:do.Provide 默认是懒加载 (Lazy loading),所以这里的注册顺序不重要。
	do.Provide(injector, NewUserService)
	do.Provide(injector, NewDBRepository)

	fmt.Println(`容器初始化完成,准备提取服务...`)

	// 提取 UserService 并使用。
	// 此时才会触发 UserService 和 DBRepository 的实例化。
	userService := do.MustInvoke[*UserService](injector)
	userService.PrintUser()
}

输出结果:

text
容器初始化完成,准备提取服务...
--> 初始化 UserService
--> 初始化 DBRepository
User is: Alice

示例 2:面向接口编程 (Interface Binding)

在实际开发中,我们通常将结构体绑定到接口上,以便于单元测试时使用 Mock 替换。

go
package main

import (
	`fmt`
	`github.com/samber/do/v2`
)

// 定义一个接口
type Notifier interface {
	Send(msg string)
}

// 实现该接口的具体结构体
type EmailNotifier struct{}

func (e *EmailNotifier) Send(msg string) {
	fmt.Println(`发送邮件通知:`, msg)
}

// 构造函数:返回的是接口类型 Notifier,而不是 *EmailNotifier
func NewEmailNotifier(i do.Injector) (Notifier, error) {
	return &EmailNotifier{}, nil
}

func main() {
	injector := do.New()

	// 注册接口与其实现
	do.Provide(injector, NewEmailNotifier)

	// 提取时,通过接口类型获取
	notifier := do.MustInvoke[Notifier](injector)
	notifier.Send(`系统异常警告!`)
}

示例 3:生命周期管理 (优雅关闭 Graceful Shutdown)

samber/do 非常擅长管理资源的生命周期。如果你的结构体实现了 Shutdown() error 方法,当你销毁容器时,samber/do 会自动按照依赖的反向顺序调用它们的关闭方法。

go
package main

import (
	`fmt`
	`github.com/samber/do/v2`
)

type Database struct {
	conn string
}

// 实现 Shutdown() error 接口
func (db *Database) Shutdown() error {
	fmt.Println(`>>> 关闭数据库连接:`, db.conn)
	db.conn = ``
	return nil
}

func NewDatabase(i do.Injector) (*Database, error) {
	fmt.Println(`<<< 开启数据库连接`)
	return &Database{conn: `tcp://localhost:3306`}, nil
}

func main() {
	injector := do.New()
	do.Provide(injector, NewDatabase)

	// 触发实例化
	_ = do.MustInvoke[*Database](injector)

	fmt.Println(`程序运行中...`)

	// 销毁容器,自动调用所有已实例化服务的 Shutdown() 方法
	// 建议在 main 函数使用 defer injector.Shutdown()
	injector.Shutdown()

	fmt.Println(`程序退出`)
}

输出结果:

text
<<< 开启数据库连接
程序运行中...
>>> 关闭数据库连接: tcp://localhost:3306
程序退出

示例 4:其他常见的注入方式

除了标准的懒加载 do.Provide 外,你还可以直接注入一个已经存在的值,或者注入瞬态(每次请求都新建)的服务:

go
package main

import (
	`fmt`
	`github.com/samber/do/v2`
)

type Config struct {
	Port int
}

type RandomGenerator struct{}

func NewRandomGenerator(i do.Injector) (*RandomGenerator, error) {
    fmt.Println(`创建新的 Generator 实例`)
	return &RandomGenerator{}, nil
}

func main() {
	injector := do.New()

	// 1. ProvideValue: 直接注入一个已存在的对象/值 (非常适合配置项注入)
	cfg := &Config{Port: 8080}
	do.ProvideValue(injector, cfg)

	// 2. ProvideTransient: 瞬态服务注入 (每次 Invoke 都会执行构造函数创建一个新实例)
	do.ProvideTransient(injector, NewRandomGenerator)

	// 测试获取 Config
	loadedConfig := do.MustInvoke[*Config](injector)
	fmt.Println(`Config Port:`, loadedConfig.Port)

	// 测试获取 Transient 服务
	_ = do.MustInvoke[*RandomGenerator](injector) // 打印: 创建新的 Generator 实例
	_ = do.MustInvoke[*RandomGenerator](injector) // 打印: 创建新的 Generator 实例
}

总结与最佳实践:

  1. 构造函数签名固定:你的服务构造函数必须始终接受 do.Injector 并返回 (YourType, error)
  2. 使用 MustInvoke:一般在项目初始化阶段提取服务时使用 do.MustInvoke,如果依赖不存在它会直接 panic,符合 Go 在启动阶段“尽早暴露错误”的原则。如果你想优雅处理错误,可以使用 do.Invoke
  3. 接口解耦:尽量让构造函数返回接口,提取(Invoke)时也使用接口泛型,这样依赖只绑定于抽象,业务代码会非常干净。