本讲的主题是:控制反转与依赖注入。从名称上看,似乎很“高大上”。上一讲结尾处也提到:这将是反射专题最难以理解的部分。其实,说它难,主要原因是和前面我们一直使用的函数(或方法)调用方式不同。只要转换了思维,理解和使用起来其实就很简单了。

# 了解概念

我们先来说“控制反转”。

试想一下,现有名为 test() 的函数,如下:

go
复制代码func test() {
   fmt.Println("我被调用了!")
}

如果要调用该函数,通常使用的方法是直接主动发起调用,即:

go
复制代码test()

得到运行结果:

我被调用了

控制反转就是将上述主动调用行为变成间接行为。这种间接调用的方式是很多框架的核心设计理念,如 Web 框架 Martini 等等。如果使用过类似框架,或熟悉 Java 中的 Spring 框架的话就更容易理解。发起主动调用的一方不再直接调用某个函数,而是通过某些配置,将配置信息交给框架,让框架完成函数调用。某些集成框架的程序甚至是由框架驱动的,使用这类框架构建程序的模式被称为“控制反转”。

控制反转的主要优势在于调用方和执行方的解耦。正如前文中所述,调用方可以通过将配置信息“告知”框架,动态调用函数,使程序运行更加自由、多样化。

依赖注入是实现控制反转的一种方法。空谈控制反转没有实际意义,依赖注入可以让控制反转变成现实。

# 依赖注入

默认安装的 Go SDK 并没有提供依赖注入支持,需要引入第三方包(有时也叫做库、源码包等等)来进行。本讲所采用的第三方包名为“inject”,源码位于 Github (opens new window)

还记得怎样集成第三方包吗?没错,使用 go get 命令。

打开终端,将目录定位到工程根目录下,执行:

shell
复制代码go get github.com/codegangsta/inject

稍等片刻,若没有异常输出且终端回到等待输入命令状态,则表示包引入完成。

💡 提示:由于网络原因,直接访问 Github 可能会失败。解决方法是使用代理连接代替直接连接,方法是在终端执行 go env -w GOPROXY=https://goproxy.cn,即可使用 goproxy.cn 作为代理服务器。

接下来,我们通过依赖注入的方式调用前面的 test() 函数,具体代码如下:

go
复制代码func main() {
   injector := inject.New()
   injector.Invoke(test)
}

如上代码所示,使用依赖注入调用函数需要两个步骤。首先调用 inject.New() 函数,该函数将返回一个 Injector 类型值。本例将其赋值给 injector 变量。随后便可通过 injector 执行 Invoke() 方法调用 test() 函数了。需要注意的是向 Invoke() 方法传递参数时,只需传入不带小括号的方法名

看到这,是不是感觉有点熟悉呢?在上一讲中,讲到通过反射调用函数时也是这样通过方法名调用方法的。实际上,inject 包就是通过反射实现的

掌握基本的函数调用后,接下来就是调用时的传参和接收返回值了。传参的过程又被称为注入参数的过程,inject 提供了注入参数的通用方式。比如喜闻乐见的 addCalc() 函数:

go
复制代码type myInt interface{}
func addCalc(num1 int, num2 myInt) int {
   return num1 + num2.(int)
}

如上代码所示,addCalc() 需要两个参数。

inject 要求使用 Map() 和 MapTo() 方法注入参数Invoke() 方法包含两个返回值,一个是切片类型,存放的数据是 reflect.Value,即函数返回值;另一个是包含错误信息的 error 类型数据,若该数据不为 nil,则表示函数调用发生了错误

对于本例,调用 addCalc() 函数的流程就是先进行参数注入,再通过依赖反转发起调用,最后从调用结果中获取函数执行的返回值。具体代码如下:

go
复制代码func main() {
   injector := inject.New()
   injector.Map(10)
   injector.MapTo(20, (*myInt)(nil))
   result, err := injector.Invoke(addCalc)
   if err == nil {
      fmt.Println(result[0])
   } else {
      panic(err)
   }
}

程序运行后,控制台将输出:

30

对于获取函数返回值,相信大家都能看得到。但是注入参数的两行代码就有疑问了:为什么要用 Map() 和 MapTo() 两个方法,用两次 Map() 为何不可?为什么在 addCalc() 函数中,明明用两个 int 值作为参数就行,却要用一个 myInt 类型?为什么 myInt 类型得是 interface{} 呢……

我们不妨将 MapTo() 换为 Map(),同时修改 addCalc() 的参数和函数体,即:

go
复制代码func addCalc(num1 int, num2 int) int {
	return num1 + num2
}
func main() {
   injector := inject.New()
   injector.Map(10)
   injector.Map(20)
   result, err := injector.Invoke(addCalc)
   if err == nil {
      fmt.Println(result[0])
   } else {
      panic(err)
   }
}

再次运行这段程序,控制台将输出:

40

是不是更加迷惑了?如果在 addCalc() 函数中输出 num1 和 num2 的值,会发现它们都是居然都是 20!这到底是怎么一回事呢?

# 依赖注入原理

要解答上面的问题,就要深入 inject 源码层面了。好在 inject 包较为简单,只需关注 inject.go 即可。

从一开始的 import 部分可以看出,整个 inject 源码只导入了两个 Go 内置包:fmt 和 reflect。从这一点可以看出:依赖注入的实现以反射(reflect)为基础

紧接着的是 4 个接口定义:

go
复制代码type Injector interface {
   Applicator
   Invoker
   TypeMapper
   SetParent(Injector)
}
type Applicator interface {
   Apply(interface{}) error
}
type Invoker interface {
   Invoke(interface{}) ([]reflect.Value, error)
}
type TypeMapper interface {
   Map(interface{}) TypeMapper
   MapTo(interface{}, interface{}) TypeMapper
   Set(reflect.Type, reflect.Value) TypeMapper
   Get(reflect.Type) reflect.Value
}

显然,Injector 接口是 Applicator、Invoker 和 TypeMapper 的父接口。Applicator 接口内部包含 Apply() 函数,用于注入结构体内成员;Invoker 接口内部包含 Invoke() 函数,用于调用函数;TypeMapper() 接口中包含 4 个函数,前 3 个函数都用于向函数或方法中注入参数,Get() 函数用于获取注入的参数。要区分前 3 个函数的具体不同,还要从它们各自的实现谈起。

go
复制代码func (i *injector) Map(val interface{}) TypeMapper {
   i.values[reflect.TypeOf(val)] = reflect.ValueOf(val)
   return i
}

func (i *injector) MapTo(val interface{}, ifacePtr interface{}) TypeMapper {
   i.values[InterfaceOf(ifacePtr)] = reflect.ValueOf(val)
   return i
}
func (i *injector) Set(typ reflect.Type, val reflect.Value) TypeMapper {
   i.values[typ] = val
   return i
}

可以看到,这三个函数都会通过 injector 类型的变量来调用,是 injector 类型的方法。再看 injector 类型,它是定义在源码中的唯一一个结构体,具体如下:

go
复制代码type injector struct {
   values map[reflect.Type]reflect.Value
   parent Injector
}

看到这里,我们猜到了谜底。injector 内部的 values 成员,类型是 map。而 map 本身是键-值(Key - Value)对结构,其中键不允许重复出现。当我们对同一个键反复赋值时,旧的值将会被新的值覆盖。结合 Map() 方法的实现和本例的 addCalc() 函数看,如果在注入参数时多次调用 Map() 方法反复注入 int 类型值(即 10 和 20),最早注入的值(10)会被之后注入的值(20)覆盖,所以我们的计算结果就会变成 20 + 20,最终得到 40。

所以我们要用 MapTo() 方法,将注入的参数与被执行函数所需的参数通过不同的类型进行对应。虽然废了点事儿,但确保了代码执行的正确性。

为什么示例中的 myInt 得是 interface{} 呢?因为在 inject 源码中,有这样一段:

go
复制代码func InterfaceOf(value interface{}) reflect.Type {
   t := reflect.TypeOf(value)
   for t.Kind() == reflect.Ptr {
      t = t.Elem()
   }
   if t.Kind() != reflect.Interface {
      panic("Called inject.InterfaceOf with a value that is not a pointer to an interface. (*MyInterface)(nil)")
   }
   return t
}

看到了吗?当 value 参数不属于 reflect.Interface 时,会引发宕机。整个 InterfaceOf() 函数的作用就是获取 value 参数的类型,这个函数在 MapTo() 函数中被调用。试想,如果该函数只能接收某种特定的参数,就会使注入参数时的类型变得单一化,使依赖注入的使用受限。

# 小结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

  1. 控制反转与依赖注入

本讲继续深入探讨 Go 反射的使用。

首先介绍了控制反转的概念。概括地讲就是通过框架实现函数的调用。它和传统主动发起函数调用的最大区别也就在于主动调用行为变成间接行为。优势在于调用方和执行方的解耦,使构建动态调用函数的代码成为可能。

依赖注入是实现控制反转的途径之一,本讲使用 inject 包演示了整个使用过程。为了更透彻地理解其使用方法,我带着大家详细阅读了 inject 包源码,在解答其使用上的疑惑的同时更加深了对反射的理解。

➡️ 在下次课程中,我们会介绍 Go 程序的测试和性能调优。具体内容如下:

  1. 单元测试与基准测试