实现依赖注入

思考并回答以下问题:

  • 哪些模式是结构型模式?结构型模式是为了解决什么问题?
  • 一旦发现依赖,就引入设计模式。怎么理解?
  • 降低类和类之间的耦合,就是优化和引入设计模式的方向。但是耦合不可能消失。怎么理解?
  • StrangeIOC使用了特性注入吗?

本章涵盖:

  • 背景介绍
  • 示例情景
  • Constructor注入
  • Setter注入
  • 接口注入
  • 基于Attribute实现注入--Attributer
  • 小结

背景介绍

设计模式中,尤其是结构型模式很多时候解决的就是对象间的依赖关系,变依赖具体为依赖抽象。平时开发中如果发现客户程序依赖某个(或某类)对象,我们常常会对它们进行一次抽象,形成抽象的抽象类、接口,这样客户程序就可以摆脱所依赖的具体类型。(这是开发的一种方式,一旦发现依赖,就尽量引入设计模式实现依赖抽象。)

这个过程中有个环节被忽略了——谁来选择客户程序需要的满足抽象类型的具体类型呢?通过后面的介绍你会发现很多时候创建型模式可以比较优雅地解决这个问题。但另一问题出现了,如果您设计的不是具体业务逻辑,而是公共库或框架程序,这时候您是一个“服务方”,不是您调用那些构造类型,而是它们把抽象类型传给你,怎么松散地把加工好的抽象类型传递给客户程序就是另一回事了。

这个情形也就是常说的“控制反转”,IOC:Inverse of Control;框架程序与抽象类型的调用关系就像常说的好莱坞规则:Don’t call me, I’ll call you。

参考Martin Fowler在《Inversion of Control Containers and Dependency Injection pattern》一文,我们可以采用“依赖注入”的方式将加工好的抽象类型实例“注入”到客户程序中,本书的示例也将大量采用这种方式将各种依赖项“注入”到模式实现的外部——客户程序。下面我们结合一个具体的示例看看为什么需要依赖注入,以及Martin Fowler文中提到的三种经典方式,然后依据C#语言的特质,再扩展出一个基于Attributer方式注入(参考原有的Setter命名,这里将基于Attribute的方法称为Attributer)。

示例情景

客户程序需要一个提供System.DateTime类型当前系统时间的对象,然后根据需要仅仅把其中的年份部分提取出来,因此最初的实现代码如下:

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
using System;
using System.Diagnostics;

namespace MarvellousWorks.PracticalPattern.Concept.DependencyInjection.Example1
{
class TimeProvider
{
public DateTime CurrentDate
{
get
{
return DateTime.Now;
}
}
}

public class Client
{
public int GetYear()
{
TimeProvider timeProvider = new TimeProvider();
return timeProvider.CurrentDate.Year;
}
}
}

客户端什么都不干嘛,只进行调用得到结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Program.cs
using System;
using MarvellousWorks.PracticalPattern.Concept.DependencyInjection.Example1;

namespace ConsoleApplication
{
class Program
{
static void Main(string[] args)
{
Client cl = new Client();
int year = cl.GetYear();
Console.WriteLine(year); // 2019

Console.ReadKey();
}
}
}

后来因为某种原因,发现使用.NET Framework自带的日期类型精度不够,需要提供其他来源的TimeProvider,确保在不同精度要求的功能模块中使用不同的TimeProvider。这样问题集中在TimeProvider的变化会影响客户程序,但其实客户程序仅需要抽象地使用其获取当前时间的方法。为此,增加一个抽象接口,确保客户程序仅依赖这个接口ITimeProvider,由于这部分客户程序仅需要精确到年,因此它可以使用一个名为SystemTimeProvider(:ITimeProvider)的类型。新的实现代码如下:

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
using System;
using System.Diagnostics;

namespace MarvellousWorks.PracticalPattern.Concept.DependencyInjection.Example1
{

interface ITimeProvider
{
DateTime CurrentDate { get; }
}

class TimeProvider : ITimeProvider
{
public DateTime CurrentDate
{
get
{
return DateTime.Now;
}
}
}

public class Client
{
public int GetYear()
{
TimeProvider timeProvider = new TimeProvider();
return timeProvider.CurrentDate.Year;
}
}
}

这样看上去客户程序后续处理权都依赖于抽象的ITimeProvider,问题似乎解决了?没有,它还要知道具体的SystemTimeProvider。因此,需要增加一个对象,由它选择某种方式把ITimeProvider实例传递给客户程序,这个对象被称为Assembler。新的结构如图1所示。

图1 增建装配对象后新的依赖关系

其中,Assembler的职责如下:

  • 知道每个具体TimeProviderImpl的类型。
  • 可根据客户程序的需要,将抽象ITimeProvider反馈给客户程序。
  • 本身还负责对TimeProviderImpl的创建。

下面是一个Assembler的示例实现:

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
38
39
40
using System;
using System.Collections.Generic;

namespace MarvellousWorks.PracticalPattern.Concept.DependencyInjection
{
public class Assembler
{
/// <summary>
/// 保存“抽象类型/实体类型”对应关系的字典
/// </summary>
private static Dictionary<Type, Type> dictionary = new Dictionary<Type, Type>();

static Assembler()
{
// 注册抽象类型需要使用的实体类型
// 实际的配置信息可以从外层机制获得,例如通过配置定义。
dictionary.Add(typeof(ITimeProvider), typeof(SystemTimeProvider));
}

/// <summary>
/// 根据客户程序需要的抽象类型选择相应的实体类型,并返回类型实例
/// </summary>
/// <typeparam name="T">抽象类型(抽象类/接口/或者某种基类)</typeparam>
/// <returns>实体类型实例</returns>
public object Create(Type type) // 主要用于非泛型方式调用
{
if ((type == null) || !dictionary.ContainsKey(type))
throw new NullReferenceException();

Type targetType = dictionary[type];
return Activator.CreateInstance(targetType);
}

/// <typeparam name="T">抽象类型(抽象类/接口/或者某种基类)</typeparam>
public T Create<T>() // 主要用于泛型方式调用
{
return (T)Create(typeof(T));
}
}
}

Constructor注入

构造函数注入,顾名思义,就是在构造函数的时候,通过Assembler或其他机制把抽象类型作为参数传递给客户类型。这种方式虽然相对其他方式有些粗糙,而且仅在构造过程中通过“一锤子”方式设置好,但很多时候我们设计上正好就需要这种Read Only的注入方式。其实现方式如下:
C#

1
2
3
4
5
6
7
8
9
class Client
{
private ITimeProvider timeProvider;

public Client(ITimeProvider timeProvider)
{
this.timeProvider = timeProvider;
}
}

UnitTest

1
2
3
4
5
6
7
8
9
10
11
[TestClass]
public class TestClient
{
[TestMethod]
public void Test()
{
ITimeProvider timeProvider = (new Assembler()).Create<ITimeProvider>();
Assert.IsNotNull(timeProvider); // 确认可以正常获得抽象类型实例
Client client = new Client(timeProvider); // 在构造函数中注入
}
}

Setter注入

Setter注入是通过属性赋值的办法解决的,由于Java等很多语言中没有真正的属性,所以Setter注入一般通过一个set()方法实现,C#语言由于本身就有可写属性,所以实现起来更简洁,更像Setter。相比较Constructor方式而言,Setter给了客户类型后续修改的机会,它比较适应于客户类型实例存活时间较长,但Assembler修改抽象类型指定的具体实体类型相对较快的情景;不过也可以由客户程序根据需要动态设置所需的类型。其实现方式如下:
C#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 通过Setter实现注入
/// </summary>
class Client
{
private ITimeProvider timeProvider;

public ITimeProvider TimeProvider
{
get
{
return this.timeProvider;
} // getter本身和Setter方式实现注入没有关系
set
{
this.timeProvider = value;
}
}
}

UnitTest

1
2
3
4
5
6
7
8
9
10
11
12
[TestClass]
public class TestClient
{
[TestMethod]
public void Test()
{
ITimeProvider timeProvider = (new Assembler()).Create<ITimeProvider>();
Assert.IsNotNull(timeProvider); // 确认可以正常获得抽象类型实例
Client client = new Client();
client.TimeProvider = timeProvider; // 通过Setter实现注入
}
}

接口注入

接口注入是将包括抽象类型注入的入口以方法的形式定义在一个接口里,如果客户类型需要实现这个注入过程,则实现这个接口,客户类型自己考虑如何把抽象类型“引入”内部。实际上接口注入有很强的侵入性,除了要求客户类型增加需要的Setter或Constructor注入的代码外,还显式地定义了一个新的接口并要求客户类型实现它。除非还有更外层容器使用的要求,或者有完善的配置系统,可以通过反射动态实现接口方式注入,否则笔者并不建议采用接口注入方式。

既然Martin Fowler文中提到了这个实现方式,就给出如下示例:

C#

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
/// <summary>
/// 定义需要注入ITimeProvider的类型
/// </summary>
interface IObjectWithTimeProvider
{
ITimeProvider TimeProvider { get;set;}
}

/// <summary>
/// 通过接口方式注入
/// </summary>
class Client : IObjectWithTimeProvider
{
private ITimeProvider timeProvider;

/// <summary>
/// IObjectWithTimeProvider Members
/// </summary>
public ITimeProvider TimeProvider
{
get
{
return this.timeProvider;
}
set
{
this.timeProvider = value;
}
}
}

Unit Test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[TestClass]
public class TestClient
{
[TestMethod]
public void Test()
{
ITimeProvider timeProvider = (new Assembler()).Create<ITimeProvider>();
Assert.IsNotNull(timeProvider); // 确认可以正常获得抽象类型实例

IObjectWithTimeProvider objectWithTimeProvider = new Client();

objectWithTimeProvider.TimeProvider = timeProvider; // 通过接口方式注入
}
}

基于Attribute实现注入--Attributer

如果做个归纳,Martin Fowler之前所介绍的三种模式都是在对象部分进行扩展的,随着语言的发展(.NET从1.0开始,Java从5开始),很多在类元数据层次扩展的机制相继出现,比如C#可以通过Attribute将附加的内容注入到对象上。直观上的客户对象有可能在使用上做出让步以适应这种变化,但这违背了依赖注入的初衷,三个角色(客户对象、Assembler、抽象类型)之中两个不能变,那只好在Assembler上下功夫,谁叫它的职责就是负责组装呢?为了实现上的简洁,上面三个经典实现方式实际将抽象对象注入到客户类型都是在客户程序中(也就是那三个Unit Test部分)完成的,其实同样可以把这个工作交给Assembler完成;而对于Attrbute方式注入,最简单的方式则是直接把实现了抽象类型的Attribute定义在客户类型上,例如:

1
2
3
4
5
// 错误的实现情况
class SystemTimeAttribute : Attribute, ITimeProvider {...}

[SystemTime]
class Client {...}

相信你也发现了,这样虽然把客户类型需要的ITimeProvider通过“贴标签”的方式告诉它了,但事实上又把客户程序与SystemTimeAttribute“绑”上了,它们紧密地耦合在一起。参考上面的三个实现,当抽象类型与客户对象耦合的时候我们引入了Assembler,当Attribute方式出现类似的情况时,我们写个AttributeAssembler不就行了么?还不行。设计上要把Attribute设计成一个“通道”,考虑到扩展和通用性,它本身要协助AttributeAssembler完成ITimeProvider的装配,最好还可以同时装载其他抽象类型来修饰客户类型。示例代码如下:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
[AttributeUsage(AttributeTargets.Class, AllowMultiple=true)]
sealed class DecoratorAttribute : Attribute
{
/// <summary>
/// 实现客户类型实际需要的抽象类型的实体类型实例,即带注入到客户类型的内容
/// </summary>
public readonly object Injector;
private Type type;

public DecoratorAttribute(Type type)
{
if (type == null)
throw new ArgumentNullException("type");

this.type = type;
Injector = (new Assembler()).Create(this.type);
}

/// <summary>
/// 客户类型需要的抽象对象类型
/// </summary>
public Type Type
{
get
{
return this.type;
}
}
}

/// <summary>
/// 用户帮助客户类型和客户程序获取其Attribute定义中需要的抽象类型实例
/// </summary>
static class AttributeHelper
{
public static T Injector<T>(object target)
where T : class
{
if (target == null)
throw new ArgumentNullException("target");

Type targetType = target.GetType();

object[] attributes = targetType.GetCustomAttributes(typeof(DecoratorAttribute), false);

if ((attributes == null) || (attributes.Length <= 0)) return null ;
foreach (DecoratorAttribute attribute in (DecoratorAttribute[])attributes)
if (attribute.Type == typeof(T))
return (T)attribute.Injector;
return null;
}
}

[Decorator(typeof(ITimeProvider))]
class Client
{
public int GetYear()
{
// 与其他方式注入不同的是,这里使用的ITimeProvider来自自己的Attribute
ITimeProvider provider = AttributeHelper.Injector<ITimeProvider>(this);
return provider.CurrentDate.Year;
}
}

Unit Test

1
2
3
4
5
6
7
8
9
10
[TestClass]
public class TestClient
{
[TestMethod]
public void Test()
{
Client client = new Client();
Assert.IsTrue(client.GetYear() > 0);
}
}

小结

依赖注入虽然被Marin Fowler称为一个模式,但平时使用中,它更多地作为一项实现技巧出现,开发中很多时候需要借助这项技巧把各个设计模式所加工的成果传递给客户程序。各种实现方式虽然最终目标一致,但在使用特性上有很多区别。

  • Constructor方式:它的注入是一次性的,当客户类型构造的时候就确定了。它很适合那种生命期不长的对象,比如在其存续期间不需要重新适配的对象。此外,相对Setter方式而言,在实现上Constructor可以节省很多代码;

  • Setter方式:一个很灵活的实现方式,对于生命期较长的客户对象而言,可以在运行过程中随时适配;

  • 接口方式:作为注入方式具有侵入性,很大程度上它适于需要同时约束一批客户类型的情况;

  • 属性方式:随着开发语言的发展引入的新方式,它本身具有范围较小的固定内容侵入性(一个DecoratorAttribute),它也很适合需要同时约束一批客户类型情景。它本身实现相对复杂一些,但客户类型使用的时候非常方便——“打标签”即可。

0%