源码分析——Go语言依赖注入库 samber/do
琢磨设计模式与抽象,可以说是我的最爱之一了。刚学 Go 的时候,我就陶醉于其的 interface
设计。
这次,我们来聊聊 Go 语言的依赖注入(DI)库 samber/do。
本文不是一行行分析源码,而是尝试一步步复现作者的设计思路。
挖个坑先(为什么只讲 samber/do)
1. IoC 与 DI
首先,澄清一下,控制反转(IoC)与依赖注入(DI)是两个不同的概念。
(弄不清没关系,可以看这篇文章:还没写)
简单来说,控制反转是一种编程思想,而依赖注入是一种实现控制反转的方式。
2. 广义 IoC、DI,以及 DI Framework,以及 Go 的设计哲学
其次,似乎一提到 IoC、DI 就必须提到 Spring,甚至很多人不用 Spring 举例子,就讲不清这两个概念。
换言之,很多人都弄不清,DI 与 DI Framework 的区别。
这又牵扯到另外一个问题, Go 是否需要 IoC, DI,还是说有着更适合 Go 的依赖倒置设计哲学?
3. DI 框架的众多实现
且不说 DI 只是这些概念的一小部分,连 Go 的 DI 框架都有不少,例如:
- 因为一、二两点,导致在这之前,我应该写一篇只涉及编程思想的「论道」的文章。先欠着,由于篇幅和暂时的能力原因。
- 因为第三点,我需要对比不同的 Go 语言的 DI 框架。
- 所以,这篇文章,我只能先分析分析 samber/do 本身。
复现 samber/do 之路
先给 DI 来个狭义的定义吧:
所以我们要实现的,就是:
- 提供一个「第三方」
- 对象创建者,把特定类型的对象创建出来并注册到第三方
- 对象使用者,从第三方获取对象
1. 需要知道的前置知识
为了看懂本文,你需要知道:
- Go 的基础语法
interface
与 泛型
2. 最简单的版本
原理也很简单,我们只要维护一个 map[reflect.Type]reflect.Value
,创建方(或者说 Provider
)把对象创建出来,放到 map
里,使用方(调用方)从 map
里取出按类型取出对象即可。
Go 从 1.18 起,支持泛型,所以我们可以用泛型来简化代码。使用 map[string]any
来代替 map[reflect.Type]reflect.Value
,将类型名作为 key
,对象作为 value
。
代码如下,忽略了错误处理和并发安全:
package main
import (
"fmt"
"reflect"
)
type Injector struct {
services map[string]any
s2 map[reflect.Type]reflect.Value
}
func New() *Injector {
return &Injector{
services: make(map[string]any),
}
}
func ProvideValue[T any](i *Injector, v T) {
// ignore error handling
i.services[generateServiceName[T]()] = v
}
func Invoke[T any](i *Injector) T {
// ignore error handling
return i.services[generateServiceName[T]()].(T)
}
func generateServiceName[T any]() string {
var t T
// struct
name := fmt.Sprintf("%T", t)
if name != "<nil>" {
return name
}
// interface
return fmt.Sprintf("%T", new(T))
}
func main() {
i := New()
// pkg1
type msg string
ProvideValue[msg](i, msg("Hello World"))
// maybe in pkg2
fmt.Println(Invoke[msg](i))
// Output: Hello World
}
可以看到,在 main
函数中,
- 通过
ProvideValue
将类型为msg
的对象注册到Injector
中, - 使用的时候(可能在其他
pkg
中),通过Invoke
来获取特定类型的对象。
值得一提的是,这里注册的对象可以是任何类型,包括函数、接口等。
3. 突破单例
如果我们使用 map[reflect.Type]reflect.Value
来存储对象,那么每个类型的对象都只能有一个实例。
这也是我们使用 map[string]any
的原因,将类型名作为默认的 key
,同时用户也可以自定义 key
。
下面我们增加两个函数,分别是注入自定义 name
的对象,以及通过自定义 name
获取对象。
代码如下(补全了错误处理,但仍然忽略了并发安全):
func ProvideNamedValue[T any](i *Injector, name string, v T) {
_, ok := i.services[name]
if ok {
panic(fmt.Errorf("DI: service `%s` has already been declared", name))
}
i.services[name] = v
}
func InvokeNamed[T any](i *Injector, name string) (t T, err error) {
serviceAny, ok := i.services[name]
if !ok {
return t, fmt.Errorf("DI: could not find service `%s`", name)
}
serviceT, ok := serviceAny.(T)
if !ok {
return t, fmt.Errorf("DI: service `%s` is not of type `%T`", name, t)
}
return serviceT, nil
}
4. 对象调用链(懒加载)
上面的代码,我们能够一次注入一种类型的对象,但涉及到对象间的依赖时,我们需要按照依赖顺序,手动注入对象。
type Wheel struct{}
type Engine struct{}
type Car struct {
Engine *Engine
Wheels []*Wheel
}
以目前的实现,我们必须先注入 Engine
,再注入 Wheel
,最后注入 Car
。
代码大概是这样:
对象创建方(忽略错误处理):
engine := &Engine{}
ProvideValue[*Engine](i, engine)
wheel0, wheel1, weel2, wheel3 := &Wheel{}, &Wheel{}, &Wheel{}, &Wheel{}
ProvideNamedValue[*Wheel](i, "wheel0", wheel0)
ProvideNamedValue[*Wheel](i, "wheel1", wheel1)
ProvideNamedValue[*Wheel](i, "wheel2", weel2)
ProvideNamedValue[*Wheel](i, "wheel3", wheel3)
car := &Car{
Engine: Invoke[*Engine](i),
Wheels: []*Wheel{
// ignore error return
InvokeNamed[*Wheel](i, "wheel0"),
InvokeNamed[*Wheel](i, "wheel1"),
InvokeNamed[*Wheel](i, "wheel2"),
InvokeNamed[*Wheel](i, "wheel3"),
},
}
ProvideValue[*Car](i, car)
对象使用方:
c := Invoke[Car](i)
当创建 Car 时,我们必须先创建 Engine 和 Wheel,然后再创建 Car。否则在创建 Car 时,会因为无法注入 Engine 和 Wheel 而报错。
所以本质上,我们只实现了「单层」的依赖注入,只有最底层的对象,才能不用关心依赖到底是谁创建的;高层的对象,依然要注意依赖的对象是否已经创建。
而且,在实际使用过程中,Car, Engine, Wheel 这些对象,可能是在不同的 pkg
中创建的,初始化的关系是随机的(或者我们并不想关心初始化的顺序,我们只想在用的时候,能够拿到对象)。
怎么才能实现,无论依赖有多少级,都能够自动注入呢?
ProvideVaue
时,需要传入一个确定的值,但这个值其实可以在最后要使用的时候,才去确定。
答案呼之欲出:懒加载。代码可以这样改:
type Provider[T any] func(*Injector) (T, error)
type lazyService[T any] struct {
provider Provider[T]
}
func Provide[T any](i *Injector, provider Provider[T]) {
name := generateServiceName[T]()
ProvideNamed[T](i, name, provider)
}
func ProvideNamed[T any](i *Injector, name string, provider Provider[T]) {
_, ok := i.services[name]
if ok {
panic(fmt.Errorf("DI: service `%s` has already been declared", name))
}
i.services[name] = lazyService[T]{provider: provider}
}
其实这有点工厂模式的味道了。
我们定义了一个 Provider
类型,由用户传入,仅仅在使用的时候,才去调用 Provider
,一个个去分析依赖,然后创建对象。
现在,我们完全可以先 Provide
一个 Car
,再去 Provide
Engine
和 Wheel
,而不用关心它们的创建顺序。(只要保证,在使用前,所有的依赖都已经被 Provide
过了)
5. 重构代码
看到这里,读者可能已经发现了不对!
懒加载和前面的代码,services 的类型不一样! 这会导致我们在 Invoke
时,需要分情况处理。
解决方法同样很简单:接口 !
type Service[T any] interface {
getName() string
getInstance(*Injector) (T, error)
}
最前面提到的,直接传入值进行 Provide 的 service,我们可以称之为「饿汉service」,实现方法可以说是一览无余:
type Service[T any] interface {
getName() string
getInstance(*Injector) (T, error)
}
type eagerService[T any] struct {
name string
instance T
}
// func newEagerService ...
func (s *eagerService[T]) getName() string {
return s.name
}
func (s *eagerService[T]) getInstance(i *Injector) (T, error) {
return s.instance, nil
}
对于懒加载,我们可以称之为「懒汉service」,实现方法如下:
type lazyService[T any] struct {
name string
provider Provider[T]
instance T
built bool
}
// func newLazyService ...
func (s *lazyService[T]) getName() string {
return s.name
}
func (s *lazyService[T]) getInstance(i *Injector) (t T, err error) {
if !s.built {
// use provider to build instance
s.instance, err = s.provider(i)
if err != nil {
return t, err
}
s.built = true
}
return s.instance, nil
}
代码中忽略了错误处理与并发。
现在,我们只要往 map[string]any
中存入 Service
,到时候,无论取出的是哪种实现,都可以调用 getInstance
方法,获取到实例。
6. 生命周期
这个 DI 库是通用的,可以注入任何对象。既然是对象,有创建,就有销毁,如何实现呢?
这就是 Go 的 interface
的优势所在了:(即鸭子类型)对象无需显式声明实现了某个接口,只要实现了接口的方法,就是实现了该接口。 (本质上,接口就是一种约定,约定某物有某种行为。那么既然符合了约定,那就没必要使用 implement
一类的关键词来声明,非常灵活)。
回到本项目,我们只需要定义一个 shutdownableService
接口,然后在 getInstance
时,判断是否实现了该接口,如果实现了,就调用 shutdown
方法。代码大致如下:
type shutdownableService interface {
shutdown() error
}
func Shutdown[T any](i *Injector) error {
// ignore error handling
name := generateServiceName[T]()
serviceAny, ok := i.services[name]
if !ok {
return fmt.Errorf("DI: could not find service `%s`", name)
}
service, ok := serviceAny.(shutdownableService)
if ok {
err := service.shutdown()
if err != nil {
return err
}
}
delete(i.services, name)
return nil
}
7. 钩子
我们在 Injector
中定义类型为 func(injector *Injector, serviceName string)
的两个钩子,分别在创建对象与销毁对象时,由 framework 调用。
要注意的是,销毁时的钩子调用顺序,类似 defer
,先进后出。
8. 并发
主要用了 sync.RWMutex
,细节详见代码。
samber/do 的不足与展望
水平有限,简单谈谈我的看法。
- 所有的对象共用一个锁,对象多的时候,可能会影响性能。所以建议,尽量分散
Injector
,每个Injector
管理的对象数量不要太多。 codegangsta/inject
中拥有SetParent(Injector)
功能,可以实现,先在同层级找寻找该类型依赖,找不到的时候向父级寻找(也即,能基于此实现,子层级覆盖高层级的相同类型实例)。但该库不支持,可能也是因为,samber/do
支持一个类型多个不同名实例,一定程度上可以不需要层级。