Go中单例+依赖注入靠sync.Once延迟初始化和包级指针变量实现,依赖由外部传入而非硬编码,支持测试替换;需避免init初始化、全局直接赋值及内部new依赖。

在 Go 中实现“单例 + 依赖注入”不是靠语言特性(Go 没有类、构造器或自动 DI 容器),而是靠设计约定和包级变量 + 显式初始化控制。核心目标有两个:一是确保某个结构体全局唯一;二是让它的依赖不硬编码,支持替换(比如测试时用 mock)。关键在于“延迟初始化”和“依赖由外部传入”,而不是在结构体内 new 出来。
用 once.Do 实现线程安全的懒加载单例
避免包初始化时就创建实例(可能依赖未就绪),也避免每次调用都加锁。标准做法是结合 sync.Once 和指针变量:
var (
instance *Service
once sync.Once
)
type Service struct {
db *sql.DB
cfg Config
}
func NewService(db *sql.DB, cfg Config) *Service {
return &Service{db: db, cfg: cfg}
}
// GetInstance 返回全局唯一 *Service,首次调用时初始化
func GetInstance(db *sql.DB, cfg Config) *Service {
once.Do(func() {
instance = NewService(db, cfg)
})
return instance
}
注意:GetInstance 接收依赖参数,不自己创建 db 或读配置 —— 这就是解耦的第一步。
把依赖注入逻辑上移到应用启动层
单例本身不负责“找依赖”,而是由 main 或 cmd 层统一组装并注入。这样测试时可轻松传入 mock:
立即学习“go语言免费学习笔记(深入)”;
- main.go 中读取配置、打开数据库连接、初始化日志等
- 然后调用
service.GetInstance(db, cfg)获取实例 - 再把 service 实例传给 HTTP handler、gRPC server 等组件
例如:
func main() {
cfg := loadConfig()
db := connectDB(cfg)
svc := service.GetInstance(db, cfg) // 注入依赖
http.HandleFunc("/api/user", userHandler(svc))
log.Fatal(http.ListenAndServe(":8080", nil))
}
func userHandler(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 使用 svc.DoSomething()
}
}
接口抽象 + 构造函数参数化,为替换留出口
如果直接暴露 *Service,调用方就和具体类型耦合了。更推荐定义接口,并让构造函数接受接口依赖:
type DBInterface interface {
QueryRow(query string, args ...any) *sql.Row
}
type Service interface {
GetUser(id int) (*User, error)
}
type serviceImpl struct {
db DBInterface
cfg Config
}
func NewService(db DBInterface, cfg Config) Service {
return &serviceImpl{db: db, cfg: cfg}
}
这样单元测试时可以传入 &mockDB{},而不必动真实数据库。
避免常见陷阱
- 不要在 init() 函数里初始化单例 —— 依赖可能还没准备好,且无法传参
- 不要用全局 var 直接赋值(如
var svc = NewService(...))—— 无法延迟、无法注入、测试难 - 不要在单例方法里 new 其他服务(如
svc.db = sql.Open(...))—— 违反控制反转 - 如果需要多个配置变体的单例(如 dev/test/prod 不同 db),考虑用 map + key 区分,或改用工厂函数
不复杂但容易忽略。单例只是手段,真正价值在于依赖清晰、可测、可换。










