目录

源码分析——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 来个狭义的定义吧:

狭义DI定义
对象的使用方式不应该依赖于对象的创建方式。

所以我们要实现的,就是:

  1. 提供一个「第三方」
  2. 对象创建者,把特定类型的对象创建出来并注册到第三方
  3. 对象使用者,从第三方获取对象

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 函数中,

  1. 通过 ProvideValue 将类型为 msg 的对象注册到 Injector 中,
  2. 使用的时候(可能在其他 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 EngineWheel,而不用关心它们的创建顺序。(只要保证,在使用前,所有的依赖都已经被 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 的不足与展望

水平有限,简单谈谈我的看法。

  1. 所有的对象共用一个锁,对象多的时候,可能会影响性能。所以建议,尽量分散 Injector,每个 Injector 管理的对象数量不要太多。
  2. codegangsta/inject 中拥有 SetParent(Injector) 功能,可以实现,先在同层级找寻找该类型依赖,找不到的时候向父级寻找(也即,能基于此实现,子层级覆盖高层级的相同类型实例)。但该库不支持,可能也是因为,samber/do 支持一个类型多个不同名实例,一定程度上可以不需要层级。

彩蛋

  1. 作者的 GitHub ID 是 samber,昵称是 Samuel Berthe,很可能是取了姓和名的一部分。
  2. 作者还有其他两个好玩的开源项目,命名类似:samber/lo, samber/mo