前言
业务系统的架构需要合理的分层,目前主流的有三层架构和洋葱架构。有分层就涉及到层之间的依赖,每一层的实例依赖本层或其他层的实例,各层互相配合才能完成系统工作。怎么组装系统中各实例就涉及到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,造成循环依赖
其实在设计软件架构时,应该尽量避免循环依赖。
在架构的纵向层面,不管是层级架构还是洋葱架构中,都应该是高层依赖底层,实现依赖抽象,可变的部分依赖稳定的部分,可以越级依赖,但最好不要反向依赖。
在横向层面,可能存在两个service互相依赖的情况,这在业务上不是很合理,通常是模块的边界没有划分彻底造成,这种情况一般可以识别出公共依赖,将其抽象成一个单独的服务,或放到更低的层级,以避免循环依赖
与Spring对比
dig和spring都是能实现依赖注入的框架,开发人员只需声明某个对象需要依赖哪些对象,其依赖对象的创建,注入交由框架完成
spring支持构造器注入,字段注入等方式,其中字段注入方式支持循环依赖,dig支持构造器注入
spring默认在程序启动时就会生成所有类型的实例,并完成属性装配,dig是延迟到第一次使用再生成实例,但后续再使用都直接从缓存获取
总结
本文简单介绍了IOC设计原则,以及dig框架的基本使用及实现原理