依赖注入(DI)是一种解耦组件之间依赖关系的设计模式。在需要的时候,不同组件之间可以通过一个统一的界面获取其它组件中的对象和状态。Go语言的接口设计,避免了很多需要使用第三方依赖注入框架的情况(比如Java,等等)。我们的注入方案只提供非常少的类似Dager或Guice中的注入方案,而专注于尽量避免手动去配置对象和组件之间的依赖关系。因为,我们认为如果在Go代码库中,注入能够更加容易理解,就根本没有必要那样。
在Go中实现注入只需要这几个简单的步骤:
全局变量
先从一个一致的、崇高的目标开始,我们需要一些如Mongo、Memcache等服务的全局连接对象。大致是这样的:
通常 main() 函数会调用配置在flags或configuration文件中如 InitMongoService 这样的各种初始化函数。这时,像 GetApp 这样的函数就可以使用这些服务和连接了。当然,有时候我们会忘记初始化全局变量,被 nil 引发panic。
虽然在创建全局变量的时候共享资源让它们(至少)有两个缺点:
首先,因为组件的依赖关系不明确,所以代码是很难写的;
其次,你很难去测试你写的代码,在并行条件下更是几乎不可能。
尽管测试是非常快的(我们希望确保一直很快),但是能够在并行环境下测试才是最重要的。使用全局连接对象时,后台服务无法在并发条件下测试出相同的数据。
清除全局变量
为了清除全局变量,我们先从一个通用模式开始。我们的组件现在显示依赖,我们将,一个Mongo服务,或者一个缓存服务。大致来讲,我们上面那个幼稚的例子现在看起来应当是这样的:
许多引用全局变量的函数现在变成了结构体中存储了它们的依赖。
新的问题
真棒!在main()方法中,我们用一系列的构造代替了全局变量和函数,解决了我们之前遇到的问题。但是... 一看main()函数就知道了,太杂乱无章了。
一开始就这么乱了:
如果一直这样写下去,main()函数的方法体将会被被大量的代码占据。而这些代码仅仅只是做了两件很普通的事情:分配内存空间、装配对象和组件关系。如果我们有非常多的二进制代码和库需要引用,我们就需要一遍又一遍的写这些无聊的代码。这里特别需要注意的是,不要被nil引发panic。比如我们忘记把CacheService传递给HandlerTwo,然后就引发了一个运行时panic。我们试图构造一个方法,但是却变得有些失控。还需要写一大堆的代码手动检查nil。因为必须手动装配对象并确保运行正常,我们的开发对此非常恼火。测试人员甚至还需要自己装配对象、构建关系,显然他们不会在main()函数中共用这些代码。所以测试代码也变得越来越繁杂、冗余,却还是经常找不出实际问题。简而言之,我们解决了一个问题,却产生了另一种问题。
标识 Mundane
我们中的一些人对DI系统比较有经验,并且我们都不认为这仅仅是纯娱乐性的经验。因此,当我们第一次讨论用 DI系统解决这个新问题时,就已经有大量的push back(我理解为经验储备...高手求解)。
根据这些规则,当我们需要一些东西的时候,我们决定需要确保避免已知的复杂性并制定了一些基本准则:
没有代码生成。我们的开发编译步骤仅仅用 go install,我们不想引入额外的步骤。与这条规则相关的是无文件扫描,我们不想把项目变成一个O(大量文件)系统,同时也要防止增加编译时间。
没有子图。子图的概念是以每个请求为基准(a per-request basis)允许注入发生,简单来说,一个子图必须能够彻底地区分"global"生命周期和"per-request"(每个请求)生命周期的对象,并且确保在所有请求中不混淆这些"per-request"对象。我们决定仅仅允许"global"生命周期对象的注入,因为这正是我们现在面临的问题。
避免代码执行。DI本质上使代码很难理解,我们想避免定制化的代码执行/钩子,使它更容易理解。
根据这些准则,我们的目标变得比较清晰了:
注入应该分配对象。
注入应该将对象图连接起来。
注入应该在程序启动时仅仅运行一次。
我们也讨论了supporting constructor(支持构造函数)功能,但现在避免对他们增加支持。
注入库是这项工作的成果和我们的解决方案。它使用结构标签(struct tags)来实现注入功能,可为具体的类型注入,也支持对接口类型注入,只要明确接口类型的具体类型,它还有些不太常用的功能,比如按名称注入(named injection)。前面的简单示例现在看起来是这样:
没有任何改变,除了在 MongoService 字段上增加了注入标签。有几种不同的方式使用注入标签,但这是最常见用法,它简洁地表明了期望注入一个 mongo.Service 实例。同样地,可以想象 HandlerOne,HandlerTwo 和 RootHandler 字段上也有注入标签。
我们的main()现在看起来这样:
更短!注入的整个流程大概是这样:
查看每个已经提供的实例,最终遇到RootHandler类型的app实例.
查看RootHandler字段,寻找带 inject 标签的*HandlerOne,发现没有*HandlerOne实例存在,于是就创建一个并将它赋值给这个字段.
对刚刚创建的HandlerOne实例继续进行与步骤2类似的查找,找到AppLoader字段,简单地创建它.
对于AppLoader实例,它需要一个mongo.Service实例,它发现当我们调用Populate时已经创建过一个实例,于是它将那个实例赋值到这里.
当它对HandlerTwo进行同样的查找时,它使用已经创建的AppLoader实例,因此这两个Handlers共享这个AppLoader实例.
注入分配对象并为我们将graph连接起来。调用Populate后,注入不再做任何事情,剩下的跟之前没有注入时的行为都一样了.
胜利啦
我们的main()函数更易管控了。现在,手动新建一个仅有两个case的实例:如果实例需要在main中得到配置信息,或者如果其需要请求一个接口类型。即使如此,我们往往新建一些不完整的实例,让依赖注入为我们补充完整。测试代码大幅度的精简,并且现在可以在不需要知道对象图表的情况下为测试提供执行。这使得测试更具弹性,可以改变相当大。重构同样变得简单起来,就像抽出逻辑而不需要手动调整我们在各类main()中新建的对象图表。
总体来说,我们对结果和自从介绍了依赖注入,我们的代码库的演化感到非常高兴。
资源
你可以在Github上找到该库的资源:
我们同时提供文档,尽管最好的学习方式是实际“玩”一下:
我们非常喜爱能得到贡献,所以在贡献时请确保下面的测试可以通过: