浅析go依赖注入框架dig

3,006 阅读9分钟

前言

业务系统的架构需要合理的分层,目前主流的有三层架构和洋葱架构。有分层就涉及到层之间的依赖,每一层的实例依赖本层或其他层的实例,各层互相配合才能完成系统工作。怎么组装系统中各实例就涉及到IOC原则

IOC

IOC(Inversion of Control)即控制反转,是一种面向对象设计原则

其含义是对象获得其依赖对象的方式被反转了,在使用IOC前,每个对象实例的构造,其依赖对象的获取由该对象本身主动完成。使用IOC后,对象实例的构造和依赖对象的获取由IOC框架代劳,这部分工作还得有人做,只是不由业务对象本事来做,而是交给框架。业务对象只负责声明要依赖哪些对象,及后续对这些对象的使用

这样做有以下好处:

  • 解耦业务对象的构造和使用,业务对象中只关心业务逻辑,至于依赖怎么构造,怎么注入到本对象是业务无关的通用逻辑,交由框架处理,这也是问题分解的一个体现
  • IOC容器中通常使用单例 模式创建对象,能节省一定内存

IOC有依赖查找和依赖注入两种实现方式

  • 依赖查找:依赖查找的IOC中,对象的创建交由IOC框架负责,但其依赖需要自己从容器中查找。该方式比较贴合非IOC的对象组装模式,可用于程序运行过程中动态获取容器中的实例
  • 依赖注入:其和依赖查找的区别是,IOC框架不仅负责对象的创建,也负责将依赖注入到该对象中,真正实现了依赖反转,目前使用较多的也是依赖注入方式

下面介绍一个go语言中比较好用的依赖注入框架

dig基本使用

dig是uber开源的基于go语言的依赖注入框架,帮助开发者管理系统中对象的创建和维护,其使用一般流程如下:

  • 创建一个容器:dig.New
  • 注册构造函数:为想要让dig容器管理的实例创建构造函数,构造函数可以有多个参数和多个返回值,这些参数是这些返回值的依赖,这些返回值都会被容器管理
  • 使用这些实例:编写一个函数,将需要使用的实例作为参数,然后调用Invoke执行我们编写的函数。框架在容器中找到函数的参数类型对应的实例,并调用函数

下面是一个简单示例:

定义A,B两种类型,及其构造函数,分别返回A和B的实例

其中构造B没有其他依赖,A的构造函数有参数B,代表其依赖B实例

type A struct {

   Name string

   Pb *B

}



type B struct {

   Name string

}



func NewA(b *B) *A {

   return &A{

      Pb: b,

      Name: "a",

   }

}



func NewB() *B {

   return &B{

      Name: "b",

   }

}

接下来创建容器,分别注册A,B的构造函数,并从容器中获取实例并使用:

func main() {

   // 创建容器

   c := dig.New()

   

   // 分别注册A,B的构造函数

   err := c.Provide(NewA)

   if err != nil {

      fmt.Println(err)

      return

   }

   err = c.Provide(NewB)

   if err != nil {

      fmt.Println(err)

      return

   }



   // 从容器中获取实例并使用

   err = c.Invoke(func(a *A) {

      fmt.Println(a.Name)

      fmt.Println(a.Pb.Name)

   })

   if err != nil {

      fmt.Println(err)

   }



}

输出如下:

a

b

可见容器正确地给a注入了b的实例,并将构造好的A实例传递给用户自定义函数

默认一个类型只在容器中构造单个实例,若想容器中存入该类型的多个实例也能实现

构造函数中需通过dig.Name指定实例名,使用时需要定义一个接收对象,内嵌dig.In类型,并增加该类型字段,用name标签指定需要接收的实例名称

定义参数对象param

type param struct {

   dig.In

   B1 *B `name:"b1"`

   B2 *B `name:"b2"`

}

分别注册两个构造函数:

err = c.Provide(NewB("b1"),dig.Name("b1"))

err = c.Provide(NewB("b2"),dig.Name("b2"))

执行使用框架函数:

err = c.Invoke(func(p param) {

   fmt.Println(p.B1.Name)

   fmt.Println(p.B2.Name)

})

结果:正确获取到b1,b2实例

b1

b2

dig实现原理

接下里我们看看该框架怎么实现依赖注入

初始化容器

首先是dig.New()返回的容器是什么结构

type Container struct {

   // 保存每一个类型,由哪些node提供,通常情况下只有1个

 providers map[key][]*node



   // 保存所有的node,每调用一次provide方法,生成一个node  

 nodes []*node



   // 保存每种类型的实例值

 values map[key]reflect.Value



   // 保存每个组类型的值列表,一般很少用到,这里忽略

 groups map[key][]reflect.Value

}

key的结构如下:代表一种类型,其中name字段保存给实例命名时的name

type key struct {

   t reflect.Type

 name  string

   group string

}

注册构造函数

接下来看看注册构造函数的方法:provide

首先是根据构造函数,构造出其对应的node,其主要结构如下:

type node struct {

   // 构造函数本身

   ctor  interface{}

   // 构造函数的类型

   ctype reflect.Type

   // 该构造函数的参数类型列表

   paramList paramList

   // 该构造函数的返回值类型列表

   resultList resultList

}

构造的过程就比较简单了

func newNode(ctor interface{}, opts nodeOptions) (*node, error) {

   cval := reflect.ValueOf(ctor)

   ctype := cval.Type()

   cptr := cval.Pointer()

   // 根据类型提取出参数类型列表

   params, err := newParamList(ctype)

   if err != nil {

      return nil, err

   }



   // 提取出返回值类型列表

   results, err := newResultList(

      ctype,

      resultOptions{

         Name:  opts.ResultName,

         Group: opts.ResultGroup,

      },

   )

   if err != nil {

      return nil, err

   }

   // 生成实例

   return &node{

      ctor:       ctor,

      ctype:      ctype,

      location:   digreflect.InspectFunc(ctor),

      id:         dot.CtorID(cptr),

      paramList:  params,

      resultList: results,

   }, err

}

可以看出在privode时,并没有生成构造出任何实例,也没有办法生成实例,因为构造函数依赖的实例可能还没有其他构造函数能提供,这里只是保存了能生成实例的元数据,该元数据描述了其能产出哪些类型的实例,以及其需要什么类型的依赖

使用容器中的实例

接下来看看调用invoke时,dig怎么从容器中找到自定义方法需要的类型实例,并回调方法

例如以下示例,框架需要从容器中找到*A的实例,回调用户的func

   err = c.Invoke(func(a *A) {

      fmt.Println(a.Name)

   })

 

首先是获取方法的参数有哪些类型

ftype := reflect.TypeOf(function)

// ...

pl, err := newParamList(ftype)

if err != nil {

   return err

}

接下来就是从容器中获取这些类型的实例了

func (pl paramList) BuildList(c containerStore) ([]reflect.Value, error) {

   args := make([]reflect.Value, len(pl.Params))

   for i, p := range pl.Params {

      var err error

      // 依次build参数

      args[i], err = p.Build(c)

      if err != nil {

         return nil, err

      }

   }

   return args, nil

}

首先是检查以前是否生成过,生成过的实例会被缓存在values里,直接获取就行

if v, ok := c.getValue(ps.Name, ps.Type); ok {

   return v, nil

}

如果缓存中没有,就需要生成新的,接下来获取能生成这些类型的实例的构造方法,这些之前已经注册在providers里了

providers := c.getValueProviders(ps.Name, ps.Type)

接着调用这些构造方法

for _, n := range providers {

   // 调用构造方法

   err := n.Call(c)

   if err == nil {

      continue

   }



   // ...

}

若构造方法已有依赖,又会转而调用依赖的类型的构造方法,层层调用,直到调用到某个没有任何依赖的构造方法为止

func (n *node) Call(c containerStore) error {

   if n.called {

      return nil

   }

   

   if err := shallowCheckDependencies(c, n.paramList); err != nil {

      return errMissingDependencies{

         Func:   n.location,

         Reason: err,

      }

   }



    // 构造该构造函数的参数列表

   args, err := n.paramList.BuildList(c)

   if err != nil {

      return errArgumentsFailed{

         Func:   n.location,

         Reason: err,

      }

   }



    // 该node构造完毕,将返回值放入容器中

   receiver := newStagingContainerWriter()

   results := c.invoker()(reflect.ValueOf(n.ctor), args)

   if err := n.resultList.ExtractList(receiver, results); err != nil {

      return errConstructorFailed{Func: n.location, Reason: err}

   }

   receiver.Commit(c)

   n.called = true



   return nil

}

当拿到方法的所有参数后,调用用户的方法

returned := c.invokerFn(reflect.ValueOf(function), args)

至此整个流程结束

循环依赖

在使用容器中的示例中有句描述“直到调用到某个没有任何依赖的构造方法为止”,也就是说容器中所有对象的依赖构成一个有向无环图,那如果所有的构造函数都有依赖,框架会怎么处理呢?

我们修改下A,B的定义及构造函数,如下所示:

type A struct {

   Name string

   Pb *B

}



type B struct {

   Name string

   Pa *A

}





func NewA(b *B) *A {

   return &A{

      Pb: b,

   }

}



func NewB(a *A) *B {

   return &B{

      Pa: a,

   }

}

接着分别注入容器中

func main() {

   c := dig.New()

   c.Provide(NewA)

   err := c.Provide(NewB)

   if err != nil {

      fmt.Println(err)

   }

}

执行后提示以下错误:

cannot provide function "main".NewB: this function introduces a cycle:

可以看出dig框架不支持循环依赖,默认在注册构造函数时进行检测,也可以修改配置使其将检测推迟到使用容器中的实例时

其检测循环依赖的原理为:如果新加入的构造函数funcA的返回类型,是funcA的某个依赖的构造函数的参数类型,则视为有冲突

如下所示:要构造C,需要先构造A,B,而构造B时又依赖C,造成循环依赖

里氏替换原则.jpg

其实在设计软件架构时,应该尽量避免循环依赖。

在架构的纵向层面,不管是层级架构还是洋葱架构中,都应该是高层依赖底层,实现依赖抽象,可变的部分依赖稳定的部分,可以越级依赖,但最好不要反向依赖

横向层面,可能存在两个service互相依赖的情况,这在业务上不是很合理,通常是模块的边界没有划分彻底造成,这种情况一般可以识别出公共依赖,将其抽象成一个单独的服务,或放到更低的层级,以避免循环依赖

与Spring对比

dig和spring都是能实现依赖注入的框架,开发人员只需声明某个对象需要依赖哪些对象,其依赖对象的创建,注入交由框架完成

spring支持构造器注入,字段注入等方式,其中字段注入方式支持循环依赖,dig支持构造器注入

spring默认在程序启动时就会生成所有类型的实例,并完成属性装配,dig是延迟到第一次使用再生成实例,但后续再使用都直接从缓存获取

总结

本文简单介绍了IOC设计原则,以及dig框架的基本使用及实现原理