低配 Spring—Golang IOC 框架 dig 原理解析
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 三条方法链路的底层实现细节