控制反转、依赖注入、服务定位

最近在阅读两款依赖注入的开源框架源码——Swinject 和 Resolver,为了便于后续的源码解读,这里先写一篇文章来梳理一下相关的概念,主要涉及控制反转、依赖注入、服务定位等概念。

控制反转

控制反转(Inversion of Control,简称 IoC)是软件开发中的一种设计思想,可以降低代码间的耦合度。

这样的解释还是很抽象,那到底什么是控制反转呢?下面,我们以一个例子来进行说明。

如下所示,我们定义了一个 Company 类,其依赖并创建了 Engineer 对象。我们把这种情况称为 “控制正转”,即 Company 在内部控制了对象的初始化、属性赋值等操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protocol Employee {
func work()
}

class Engineer: Employee {
func work() {
print("code")
}
}

class Seller: Employee {
func work() {
print("sell")
}
}

class Company {
private let employee: Employee = Engineer()

func operation() {
employee.work()
}
}

假如,我们希望 CompanySeller 进行工作,那该如何进行修改?按照上面的设计逻辑,我们会对 Company 作出如下修改。

1
2
3
4
5
6
7
class Company {
private let employee: Employee = Seller()

func operation() {
employee.work()
}
}

由于设计上的缺陷,当需求发生变更时,我们不得不对原来的类进行修改,这导致我们违反了 开闭原则——对扩展开放、对修改关闭。如下所示,为上述设计的类图。

控制反转的基本思想是:Companyemployee 的控制权从内部转交至外部,由外部来控制对象的初始化、属性赋值等操作。其期望的类图如下所示,目标是移除 CompanyEmployeeImpl 实现类的依赖。这样的话,当我们修改 EmployeeImpl 实现类的类型,也无需修改 Company 类的内部代码,进而符合开闭原则。

通过一个例子,我们大致理解了 控制反转 的概念。在实际开发中,常见的实现控制反转的方式有两种:

  • 依赖注入(Dependency Injection,简称 DI
  • 服务定位(Service Locator)

下面,我们分别对依赖注入和服务定位进行介绍。

依赖注入

依赖注入的基本思想是:通过一个注入器(Injector),由它来控制 Employee 的实现类的创建,并通过各种途径注入到 Company。依赖注入的类图如下所示。

注:在某些文章或框架中,注入器也被命名为容器(Containter或 IoC Container)

依赖注入的注入方式有以下几种:

  • 构造器注入(Constructor Injection)
  • 属性注入(Property Injection)
  • 方法注入(Method Injection)
  • 接口注入(Interface Injection)
  • 注解注入(Annotation Injection)

下面,我们依次进行介绍。

构造器注入

构造器注入是指通过初始化方法进行注入,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Company {
private let employee: Employee

init(employee: Employee) {
self.employee = employee
}

func operation() {
employee.work()
}
}

class Injector {
var employee: Employee = Engineer()
lazy var company: Companny = Company(employee: employee)

func test() {
company.operation()
}
}

属性注入

属性注入也称为 setter 方法注入,即通过设置属性的方式直接进行依赖注入,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Company {
var employee: Employee?

func operation() {
employee?.work()
}
}

class Injector {
let employee: Employee = Engineer()
let company: Company = Company()

func test() {
company.employee = employee
company.operation()
}
}

方法注入

方法注入则是通过方法参数传入依赖,如下所示。属性注入其实就是一种特殊的方法注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Company {
func operation(employee: Employee) {
employee.work()
}
}

class Injector {
let employee: Employee = Engineer()
let company: Company = Company()

func test() {
company.operation(employee: employee)
}
}

接口注入

接口注入本质上和前面几种依赖注入差不多,区别在于它需要为每种依赖声明一个对应的接口,由使用方实现接口并注入依赖,如下所示。相比前几种依赖注入,接口注入略微繁琐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protocol InjectEmployee {
func inject(employee: Employee)
}

class Company {
var employee: Employee?

func operation() {
employee?.work()
}
}

extension Company: InjectEmployee {
func inject(dependency: Employee) {
employee = dependency
}
}

class Injector {
let employee: Employee = Engineer()
let company: Company = Company()

func test() {
company.inject(employee: employee)
company.operation()
}
}

注解注入

注解注入则是通过在需要注入依赖的地方添加特定的注解,通过注解背后的实现,自动注入对应的依赖,如下所示。相对而言,注解注入是一种使用更加简单的依赖注入方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@propertyWrapper
struct Injected {
private var dependency: Employee = Engineer()

var wrappedValue: Employee {
get { return dependency }
}
}

class Company {
@Injected var employee: Employee

func operation() {
employee.work()
}
}

class Tester {
let company: Company = Company()

func test() {
company.operation()
}
}

服务定位

服务定位,服务等同于被依赖的组件,定位即查找。服务定位的基本思想:一个服务定位器(Service Locator)持有所有服务,当 Company 需要 Employee 的实现类时,即可返回一个特定类型的实现类。服务定位的类图如下所示。

从类图中可以看出,服务定位与依赖注入的区别在于,服务定位只是将控制权从注入器转移到了服务定位器中。在依赖注入中,Company 是被动注入依赖,Injector 依赖 Company;在服务定位中,Company 则是主动请求服务,Company 依赖 Injector

从服务定位器的内部实现,可以将服务定位分为两类:

  • 静态服务定位
  • 动态服务定位

下面,依次进行介绍。

静态服务定位

对于静态服务定位,Service Locator 为每一种服务都提供了一个对应的方法,用于返回对应类型的服务,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
protocol Product {
func use()
}

class AppForIOS: Product {
func use() {
print("iOS")
}
}

class Company {
private let employee: Employee = ServiceLocator().getEmployee()
private let product: Product = ServiceLocator().getProduct()

func operation() {
employee.work()
product.use()
}
}

class ServiceLocator {
func getEmployee() -> Employee {
return Engineer()
}

func getProduct() -> Product {
return AppForIOS()
}
}

class Tester {
let company: Company = Company()

func test() {
company.operation()
}
}
当每次增加新的服务时,Service Locator 内部需要新增方法以支持新的服务类型。这种方式的缺点很明显,因此现有的服务定位框架基本都是采用动态服务定位的方式设计实现的。

动态服务定位

动态服务定位会在内部维护一个哈希表,哈希表能够存储各种类型的服务,并提供泛型方法以支持返回不同类型的服务。当新增服务时,也无需对 Service Locator 进行修改。如下所示,是一个简易的 Service Locator。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Company {
private let employee: Employee? = ServiceLocator.shared.resolve(type: Employee.self)
private let product: Product? = ServiceLocator.shared.resolve(type: Product.self)

func operation() {
employee?.work()
product?.use()
}
}

class ServiceLocator {
static let shared = ServiceLocator()

private var map: [Int: Any] = [:]

func register<Service>(type: Service.Type, service: Service) {
let key = ObjectIdentifier(type.self).hashValue
map[key] = service
}

func resolve<Service>(type: Service.Type) -> Service? {
let key = ObjectIdentifier(type.self).hashValue
return map[key] as? Service
}
}


class Tester {
func test() {
ServiceLocator.shared.register(type: Employee.self, service: Engineer())
ServiceLocator.shared.register(type: Product.self, service: AppForIOS())
let company = Company()
company.operation()
}
}

依赖注入 vs 服务定位

依赖注入和服务定位两者都实现了控制反转的目的。两者主要的区别在于提供服务(依赖)的方式不同。对于服务定位,使用方会显式地向 Service Locator 发起请求,而依赖注入并没有显式请求,而是被动注入。

对于服务定位,每个服务的使用方都需要依赖 Service Locator。由于 Service Locator 能够隐藏服务的相关信息,我们并不知道具体的依赖是什么,这可能会对于调试带来一些困难。

对于依赖注入,由于我们知道各个显式注入的位置,比如构造器,因此能够沿着调用栈,找到依赖创建的地方,进一步知道依赖的具体信息。

总结

本文对控制反转、依赖注入、服务定位等几个概念,结合代码进行了介绍。依赖注入根据注入的途径又可以分为多种类型,包括:构造器注入、方法注入、属性注入、接口注入、注解注入等。服务定位根据内部实现方式,可以分为静态服务定位和动态服务定位两种实现方式。依赖注入和服务定位虽然有些差异,但是两者的目标是一致的,就是为了实现控制反转。

整体上而言,控制反转虽然能够对代码进行解耦,但是凡事都有两面性。它会让代码逻辑变得更加复杂,调试也会更加困难。从这方面看来,关于是否采用控制反转,需要具体场景具体分析,权衡利弊之后再作出选择。

现在业界的 IoC、ID 框架基本上都是在支持了 动态服务定位注解注入 这两种功能的基础上,进行一些扩展,比如扩展一下服务定位的策略:是否每次查找都实例化服务?提供工厂方法进行延迟实例?是否缓存服务?...这些框架的服务定位设计思想基本上也是大同小异,基本都会在内部维护一个哈希表来缓存服务实例。

后续,有时间我们再来看看一些开源的 IoC 框架的设计。

参考

  1. Inversion of Control Containers and the Dependency Injection pattern
  2. Resolver: Introduction
  3. 一文说透依赖注入
  4. Dependency Injection Strategies in Swift
  5. Swift中依赖注入的解耦策略
  6. Service Locator 模式
  7. iOS 组件通信方案
  8. Dependency Injection 101--What and Why