欢迎光临散文网 会员登陆 & 注册

低配 Spring—Golang IOC 框架 dig 原理解析

2023-07-08 11:28 作者:清澄秋爽  | 我要投稿

0 前言

本期分享的主题是 Golang 中的 IOC 框架 dig,内容涉及到个人对编程风格的理解、对 dig 使用方法的介绍以及对 dig 底层原理的剖析.

原文: https://mp.weixin.qq.com/s/bireIkWWTQUdgc-UJEhN5g


文章内容的目录树结构如下图:

图片

 

1 IOC 框架使用背景

在引入 IOC 概念之前,我们需要先补充一些前置设定:这里主要针对“面向对象编程”和“成员依赖注入”两个问题进行探讨.

1.1 面向对象编程

首先抛出一个经典问题:“面向对象和面向过程有什么区别?”

这是个抽象的问题,本质上可以划分到哲学的范畴,涉及到个人看待世界的角度.

我是个俗人,不太会聊哲学,但是代码领域的问题,我挺能聊.

下面,我们就化抽象为具象,尝试用代码实现一个场景——“把一只大象装进冰箱”.

图片

 

在面向过程编程的视角下:

解决问题的核心是化整为零,把大问题拆解为一个个小问题,再针对小问题进行逐个击破.

在执行纲领的指导下,我们在编写代码时需要注重的是步骤的拆分与流程的串联.

下面展示一下伪代码:

 

与面向过程相对,在面向对象编程的视角之下:

一切皆为对象.

在本场景中,我选择把大象和冰箱都看成是有灵魂的角色,并且准备在交互场景中给予它们更多的参与感.

于是,这里首先塑造出大象和冰箱这两种角色(声明对象类);其次再给对应的角色注入灵魂(赋予属性和方法);最后,把主动权交还给各个角色,由它们完成场景下的互动:

 

(1)构造对象/注入灵魂

就以大象装冰箱的场景为例,我们首先我们构造出大象和冰箱两个对象,并赋予其对应的能力,比如:

  • • 大象是有生命的,它会有自己的情绪,会有行动的能力;

  • • 冰箱作为容器,除了一些基本信息之外,最重要是具有装载事物的能力.

 

 

(2)由对象完成交互

接下来,在场景的描述中,我们首先构造出参与其中的各个对象,然后通过各对象本身固有的能力完成交互.

通过上述例子,希望能帮助大家对面向对象的编程哲学产生更直观的感受.

 

1.2 成员依赖注入

在日常业务代码的编写中,我个人会比较推崇面向对象的代码风格,原因如下:

  • • 面向对象编程具有封装、多态、继承的能力,有利于系统模块之间的联动

  • • 将系统各模块划分为一个个对象,划分边界、各司其职,使得系统架构层级分明、边界清晰

  • • 对象中的核心成员属性能够被复用,避免重复构造

 

上述的第三点需要基于面向对象编程+成员依赖注入的代码风格加以保证.

成员依赖注入是我在依赖注入Dependency Injection(DI)概念的基础上小小调整后得到的复合概念,其指的是,在程序运行过程中,当对象A需要依赖于另一个对象B协助完成工作时,不会在代码中临时创建对象B的实例,而是遵循以下几个步骤:

  • • 前置将对象B声明为对象A的成员属性

  • • 在初始化对象A的构造函数暴露出A对B的依赖

  • • 于是在A初始化前,先压栈完成B的初始化工作

  • • 将初始化后的B注入A的构造函数,完成A的初始化

  • • 在A的生命周期内,完成对成员属性B的复用,不再重复创建

 

下面进一步举正反例子,对比成员依赖注入这一思想对代码风格带来的影响:

背景中,我们有三个对象,分别是:

  • • 和数据源打交道的 DAO

  • • 和第三方服务通信交互的 Client

  • • 聚集了核心业务流程的 Service,且 Service 会依赖于 DAO 和 Client 的能力

 

(1)无成员依赖注入

首先给出不遵循成员依赖注入的代码反例:

 

在上述代码中,存在的两个局限性在于:

  • • dao、client 等核心组件的生命周期局限于一个业务方法中,因此会被重复创建. 这类组件内部本身还有依赖,其初始化过程通常是比较”重“的. 因此其多次重复创建/销毁的行为可能会带来严重的性能损耗

  • • Service 与 dao、client 强耦合,模块定位丧失灵活度. 这一点目前看来说得不够直观,可以相较第(2)部分来看

 

(2)遵循成员依赖注入

 

 

这种成员依赖注入风格的代码具有的特点包括:

  • • 依赖的核心组件一次注入,永久复用,没有重复创建所带来的成本

  • • 就近将成员抽象为 interface 后,基于多态的思路,Service 本身的定位更加灵活,取决于注入的成员变量的具体实现

图片

举例说明,把 dao 和 client 定义为 interface 后,

  • • 当注入和食物数据库交互的 foodDAO 和食物服务交互的 foodClient 时,service 就被定位成处理食物业务的模块

  • • 当注入和饮品数据库交互的 drinkDAO 和饮品服务交互的 drinkClient 时,service 就被定位成处理饮品业务的模块

  • • ...

 

foodClient + foodDAO -> foodSerivce

drinkClient + drinkDAO -> drinkSerivce

...

 

更进一步,倘若我们需要编写模块的单测代码,还可以实现 mock 成员变量的注入,从而实现外置依赖的代码逻辑的打桩,让单测逻辑能够好地聚焦在 Service 领域的业务代码:

  • • 当注入 mockDAO 和 mockClient 时,service 就被成为了一个仅用于测试的 mock 业务模块.

 

 

1.3 引入 IOC 的原因

在 1.2 小节的基础上做个延伸性的探讨,倘若所有代码都严格遵循这种成员依赖注入的风格,一旦系统架构变得复杂,就会有新的问题产生:

(1)大量的依赖对象

图片

 

倘若对象A依赖的成员模块数量很大,每个成员都需要由构造器的调用方通过入参进行显式注入,这样编写起来代码复杂度过高:

 

(2)重复的依赖对象

图片

 

此外,依赖路径可能存在交叉的情况,最终形成一张错综复杂的依赖网,此时就会产生两个问题:

  • • 倘若某个子对象被多个父对象所依赖,如何保证子对象维持为单例状态,能够被全局复用

  • • 如何梳理好复杂的依赖路径,保证依赖注入流程的正常执行

 

举个代码示例如下:

 

梳理完上述问题后,我们的诉求也逐渐清晰:

  • • 需要有一个全局的容器,实现对各个组件进行缓存复用

  • • 需要有一个全局管理对象,为我们梳理各对象间的依赖路径,依次完成依赖注入的工作

而本文的主题—— IOC 框架,扮演的正是这样一个角色.

IOC,全称 Inversion of Control 控制反转,指的是将业务组件的创建、复制、管理工作委托给业务代码之外的容器进行统一管理. 我们通常把容器称为 container,把各个业务组件称为 bean.

由于各个 bean 组件之间可能还存在依赖关系,因此 container 的另一项能力就是在需要构建 bean 时,自动梳理出最优的依赖路径,依次完成依赖项的创建工作,最终产出用户所需要的 bean.

在这个依赖路径梳理的过程中,倘若 container 发现存在组件缺失,导致 bean 的依赖路径无法达成,则会抛出错误终止流程. 通常这个流程会在编译阶段或者程序启动之初执行,因此倘若依赖项存在缺失,也能做到尽早抛错、及时止损,引导开发人员提前解决代码问题.

 

1.4 Golang IOC 框架 dig

(1)dig 基本信息

聊到 IOC 框架,JAVA 中的 Spring 是一座绕不过的大山. 相对于生态成熟资源丰富的 JAVA 而言,Golang 中成熟可用的 IOC 框架就相对有限.

而今天我们要介绍的主角是由 uber 开源的 dig,git开源地址为:https://github.com/uber-go/dig,本文走读的源码版本为 tag v1.15.

 

图片

 

(2)dig 与 spring 的差距

dig 能够为研发人员提供到前文提及的两项核心能力:

  • • bean 单例管理

  • • bean 依赖路径梳理

同时,本着实事求是的态度,我们也如实阐述一下 dig 相比于 spring 所缺失的能力:

(1)只有 IOC,不具有 AOP (Aspect Oriented Programming)的能力

(2)在同一个 key 下(bean type + bean name/group)只支持单例,不支持原型

(3)将 bean 注入 container 的方式相对单调,强依赖于构造器函数的模式

(4)由于依赖于构造器函数,因此不能解决循环依赖问题(事实上,在Golang 中,本就不支持循环依赖的模式,跨包之间的循环依赖引用,会在编译层面报错)

(5)bean 没有支持丰富的生命周期方法

 

2 dig 使用教程

2.1 provide/invoke

图片

 

首先给出代码示例,供大家更直观地感受通过 dig 实现依赖注入、路径梳理、bean 复用的能力:

  • • 存在 bean A、bean B,其中 bean A 依赖于 bean B

  • • 声明 bean A 和 bean B 的构造器方法,A 对 B 的依赖关系需要在构造器函数 NewA 的入参中体现

  • • 通过 dig.New 方法创建一个 dig container

  • • 通过 container.Provide 方法,分别往容器中传入 A 和 B 的构造器函数

  • • 同归 container.Invoke 方法,传入 bean A 的获取器方法 func(_a *A),其中需要将获取器函数的入参类型设置为 bean A 的类型

  • • 在获取器方法运行过程中,入参通过容器取得 bean A 实例,此时可以通过闭包的方式将 bean A 导出到方法外层

 

输出结果:

 

2.2 dig.In

2.1 小节介绍的基本用法中,我们需要将 bean A 依赖的子 bean 统统在构造器函数中通过入参的方式进行声明,倘若依赖数量较大的话,在声明构造器函数时可能存在不便,此时可以通过内置 dig.In 标识的方式替代构造函数,标志出 A 中所有可导出的成员变量均为依赖项.

dig.In 方式的使用示例如下,其中需要注意的点是:

  • • 作为依赖 bean 的成员字段需要声明为可导出类型

  • • 内置了 dig.In 标识的 bean,在通过 Invoke 流程与 container 交互时必须使用 struct 类型,不能使用 pointer 形式

 

输出结果:

 

2.3 dig.Out

与 2.2 小节中的 dig.In 对偶,我们可以通过 dig.Out 声明,在 Provide 流程中将某个类的所有可导出成员属性均作为 bean 注入到 container 中.

与 dig.In 相仿,dig.Out 在使用时同样有两个注意点:

其中需要注意的点是:

  • • 需要作为注入 bean 的成员字段需要声明为可导出类型

  • • 内置了 dig.Out 标识的 bean,在通过 Provide 流程与 container 交互时必须使用 struct 类型,不能使用 pointer 形式

 

 

输出结果:

 

2.4 bean name

此外,倘若存同种类型存在多个不同的 bean 实例,上层需要进行区分使用,此时 container 要如何进行标识和管理呢,答案就是通过 name 标签对 bean 进行标记,示例代码如下:

 

输出结果:

 

2.5 bean group

倘若依赖的是 bean list 该如何处理,这就需要用到 dig 中的 group 标签.

需要注意的点是,在通过内置 dig.Out 的方式注入 bean list 的时候,需要在 group tag 中声明 flatten 标志,避免 group 标识本身会将 bean 字段上升一个维度.

 

 

3 dig 原理解析

下面明确一下 dig 框架的实现原理,首先拆解一下宏观流程中的要点:

  • • 基于注入构造函数的方式,实现 bean 的创建

  • • 基于反射的方式,实现 bean 类型到到构造函数的映射

  • • 在运行时而非编译时实现 bean 的依赖路径梳理

 

在 dig 的实现中,bean 依赖路径的梳理时机是在服务运行阶段而非编译阶段,因此这个流程应该和业务代码解耦,专门声明一个 factory 模块聚合处理的 bean 的创建工作. 避免将 bean 获取操作零星散落在业务流程各处,这样倘若某个 bean 存在依赖缺失,则会导致服务 panic.

 

3.1 核心数据结构

在方法链路的源码走读和原理解析之前,先对 dig 中几个重要的数据结构进行介绍:

图片

 

(1)Container&Scope

Container 即存放和管理 bean 的全局容器.

Scope 是一个范围块,本质上是一棵多叉树中的一个节点,拥有自己的父节点和子节点列表.

一个 Container 由一棵 Scope 多叉树构成,手中持有的是 root Scope 的引用.

目前在笔者的工程实践中未涉及到对 Scope 的使用,通常只使用一个 root Scope 就足以满足完使用诉求.

因此,在本文的介绍中,大家可以简单地把 Container 和 Scope 认为是等效的概念.

 

 

 

(2)key

key 是容器中的唯一标识键,由一个二元组构成. 其中一维是 bean 的类型 reflect.Type,另一维是 bean 名称 name 或者 bean 组名 group.

此处 name 字段和 group 字段是互斥关系,二者只会取其一. 因为一个 bean 被 provide 的时候,就会明确其是 single 类型还是 group 类型.

 

(3)constructorNode

图片

 

constructorNode 是构造器函数的封装节点,包含的核心字段包括:

  • • ctor:bean 构造器函数

  • • ctype:bean 构造器函数类型

  • • called:构造器函数是否已被执行过

  • • paramList:构造器函数依赖的入参

  • • resultList:构造器函数产生的出参

 

 

(4)param

paramList 是构造器节点的入参列表:

  • • ctype:构造器函数的类型

  • • params:入参列表

 

入参 param 本身是个 interface,核心方法是 Build,逻辑是从存储介质(容器) containerStore 中提取出对应于当前 param 的 bean,然后通过响应参数返回其 reflect.Value.

 

param 的实现类包括:

单个实体 bean,除了我们内置 dig.In 标识和通过 group 标签标识的情况,其他的入参 bean 都属于 paramSingle 的形式.

 

通过 group 标签标识的 bean group

 

内置了 dig.In 的 bean

 

(5)result

resultList 是构造器函数节点的出参列表:

  • • ctype:构造器函数的类型

  • • Results:出参列表

 

出参 result 本身是个 interface,核心方法是 Exact,方法逻辑是将已取得的 bean reflect.Value 填充到容器 containerWriter 的缓存 map values 当中.

 

result 的实现类包括:

单个实体 bean,除了我们内置 dig.Out 标识和通过 group 标签标识的情况,其他的出参 bean 都属于 resultSingle 的形式.

 

基于 group 标签标识的 bean group

 

内置了 dig.out 的 bean.

 

 

3.2 构造全局容器

图片

 

(1)dig.New

创建 dig 容器通过 dig.New 方法执行,方法中会创建一个 Container 实例,并创建一个 rootScope 注入其中.

 

(2)dig.newCope

newScope 方法中创建了一个 Scope 实例,对 Scope 数据结构中的几个 map 成员变量进行了初始化.

值得一提的是,此处声明了获取bean 的入口函数 invokerFn 为 defaultInvoker. 其核心逻辑我们在 3.4 小节第(6)部分展开介绍.

 

 

3.3 注入 bean 构造器

图片

在 dig 中,将 bean 注入的方式有两类:

  • • 一种是在 bean 中内置 dig.In 标识,执行一次 Invoke 方法会自动完成 bean 的注入工作

  • • 另一种是通过 Container.Provide 方法,传入 bean 的构造器函数.

Container.Provide 是主链路,接下里沿着该方法进行源码走读.

(1)Container.Provide

经由 Container.Provide -> Scope.Provide 的链路调用后,完成了对构造器函数的类型和配置的检查,随后步入 Scope.provide 方法中.

 

 

(2)Scope.provide

Scope.provide 方法中完成的工作是:

  • • 调用 newConstructorNode 方法,将构造器函数封装成一个 node 节点

  • • 调用 Scope.findAndValidateResults 方法,通过解析构造器出参的类型以及用户定制的 bean 名称/组名,封装出对应于出参个数的 key

  • • 将一系列 key-node 对添加到 Scope.providers map 当中,供后续的 invoke 流程使用

  • • 将新生成的 node 添加到 Scope.nodes 数组当中

 

(3)newConstructorNode

newConstructorNode 方法完成了将构造器函数 ctor 封装成节点的任务,其中包含几个核心步骤:

  • • 调用 newParamList 方法,将入参封装成 param 列表的形式,但还没有真正从 container 中获取 bean 执行 param 的填充动作

  • • 调用 newResultList 方法,将出参封装成 result 列表的形式,同样只做封装,没有执行将 result 注入容器的处理

  • • 结合构造器函数 ctor、入参列表 param list 和出参列表 result list,构造 constructorNode 并返回

 

(4)newParamList

newParamList 方法中,会根据 reflect 包的能力,获取到构造器函数的入参信息,并将其调用 newParam 方法将每个入参封装成 param 的形式.

 

在 newParam 方法中,会根据入参的类型,采用不同的构造方法,包括 paramSingle 和 paramObject 的类型.

 

(5)newResultList

newParamList 方法中,会根据 reflect 包的能力,获取到构造器函数的出参信息,并将其调用 newReject 方法将每个出参封装成 result 的形式.

 

在 newResult 方法中,会根据出参的类型,采用不同的构造方法,包括 resultSingle 和 resultObject、resultGroup 的类型.

 

3.4 提取 bean

图片

 

从容器中提取 bean 的入口是 Container.Invoke 方法,需要将 bean 提取器函数作为 Invoke 的第一个入参,并将提取器函数的入参声明成 bean 对应的类型.

在 dig 提取 bean 的链路中,正是根据提取器函数的入参类型作反射,从容器中提取出对应的 bean.

 

(1)Container.Invoke

在 Container.Invoke-> Scope.Invoke 的链路中:

  • • 针对提取器函数 function 和配置项 opts 进行了校验

  • • 通过 shallowCheckDependencies 方法进行了依赖路径的梳理,保证容器中已有的组件足以支撑构造出本次 Invoke 需要获得的 bean

  • • 调用 newParamList 方法,通过提取器函数的入参,构造出所需的 params 列表

  • • 调用 paramList.BuildList 方法,真正地从容器中提取到对应的 bean 集合,通过 args 承载

  • • 调用 Scope.invokerFn 方法,传入提取器函数 function 和对应的入参 args,通过反射机制真正地执行提取器函数 function,在执行过程中,入参 args 就已经是从容器中获取到的 bean 了

 

 

(2)param.Build

paramList.BuildList 方法,会遍历 params 列表,对每个 param 依次执行 param.Build 方法,从容器中获取到 bean 填充到 args 数组中并返回.

 

以 param interface 的实现类 paramSingle 为例,paramSingle.Build 方法的执行步骤包括:

  • • 倘若 bean 已经构造过了,则通过 container.getValue 方法直接从 container.values 中获取缓存好的 bean 单例进行复用

  • • 调用 container.getValueProviders 方法,获取 bean 对应的 constructorNode

  • • 调用 constructorNode.Call 方法,通过执行 bean 的构造器函数创建 bean 并将其注入到 container.values 缓存 map 中

  • • 再次调用 container.getValue 方法,从 container.values 缓存 map 中获取 bean 并返回

 

(3)constructorNode.call

constructorNode.call 方法核心步骤包括:

  • • 通过 constructorNode.called 标识,保证每个构造器函数不被重复执行

  • • 调用 shallowCheckDependencies 方法,检查构造器节点 constructorNode 入参对应的 paramList 的依赖路径是否完成

  • • 调用 paramList.BuildList 方法,将构造器节点依赖的入参 args 构造出来(此时会递归进入 3.4小节第(2)部分,从容器中提取 bean 填充构造器函数的入参 )

  • • 调用 Scope.invoker 方法,将构造器函数 constructorNode.ctor 及其入参 args 传入,通过reflect 包的能力真正执行构造器函数,完成 bean 的构造

  • • 调用 resultList.ExactList 方法,将构造生成的 bean 添加到 container.values 缓存 map 中

  • • 将 constructorNode.called 标识标记为 true,代表构造器函数已经执行过了

 

(4)result.Extract

在 resultList.ExtractList 方法中,会遍历传入的 results,分别执行 result.Extract 方法,依次将 bean 添加到 container.values 缓存 map 中.

 

同样以 resultSingle 为例,方法核心逻辑是以 result 的名称和类型组成唯一的 key,以 bean 为 value,将 key-value 对添加到 contaienr.values 缓存 map.

 

(5)Scope.invokerFn

Scope 的 invokerFn 是获取 bean 的入口函数,默认使用 defaultInvoker 函数.

 

defaultInvoker 函数的形参分别为构造器函数及其依赖的入参,方法内部会依赖 reflect 库的能力,执行构造器函数,并将响应结果返回.

 

 

4 总结

最后来盘点一下本期我们讨论到的内容:

  • • 介绍了引入 Golang IOC 框架 dig 的背景——面向对象编程+成员依赖注入的代码风格

  • • 介绍了 dig 的基本用法:(1)创建容器 dig.New;(2)注入 bean 方法:Container.Provide;(3)提取 bean 方法:Container.Invoke

  • • 基于源码走读的方式,串讲了通过 dig 创建容器、注入 bean 构造器和提取 bean 三条方法链路的底层实现细节


低配 Spring—Golang IOC 框架 dig 原理解析的评论 (共 条)

分享到微博请遵守国家法律