依赖注入 – ASP.NET MVC 4 系列
从 ASP.NET MVC 3.0 开始就引入了一个新概念:依赖解析器(dependence resolver)。极大的增强了应用程序参与依赖注入的能力,更好的在 MVC 使用的服务和创建的一些类(控制器和视图页面)之间建立依赖关系。为更好的理解依赖解析器的工作原理,下面首先定义一些它所用到的通用软件模式。
软件设计模式
软件设计模式主要用来规范问题及其解决方案的描述,简化开发人员对常见问题及其对应解决方案的标识与交流。设计模式并不是新奇的发明,而是为行业中常见的实践给出一个正式的名称和定义。
设计模式 - 控制反转模式
几乎每个人都见过或编写过类似下面的代码:
public class EmailService
{
public void SendMessage()
{
//...
}
}
public class NotificationSystem
{
private EmailService svc;
public NotificationSystem()
{
svc = new EmailService();
}
public void InterestingEventHappened()
{
svc.SendMessage();
}
}
上面的代码中,NotificationSystem 类依赖于 EmailService 类。当一个组件依赖于其他组件时,我们称其为耦合(coupling)。本例中,通知系统(NotificationSystem)在其构造方法内部直接创建 e-mail 服务的一个实例,换言之,通知系统明确地知道创建和使用了哪种类型的服务。这种耦合表示了代码的内部链接性。一个类知道与其交互的类的大量信息(正如上面的示例),我们称其为高耦合。
在软件设计过程中,高耦合通常认为是软件设计的责任,当一个类精确的知道另一个类的设计和实现时,就会增加软件修改的负担,因为修改一个类很有可能破坏依赖于它的另一个类。上面的代码还存在一个问题,当感兴趣的事件发生时,通知系统如何发送其他类型的信息?例如系统管理员可能想得到文本信息,或者想把每个通知都记录到数据库中,为了实现这些功能,我们必须重新实现 NotificationSystem 类。
为降低组件之间的耦合程度,一般采取两个独立但相关的步骤:
1. 在两块代码之间引入抽象层
在 .NET 平台中,通常使用接口(或抽象类)来代表两个类之间的抽象层。我们可以引入一个接口,并确保编写的代码只调用接口中的方法和属性,这样一来,NotificationSystem 类中的私有副本(svc)就变成了一个接口的实例,而不再是具体类型:
public interface IMessagingService
{
void SendMessage();
}
public class EmailService : IMessagingService
{
public void SendMessage()
{
//...
}
}
public class NotificationSystem
{
private IMessagingService svc;
public NotificationSystem()
{
svc = new EmailService();
}
public void InterestingEventHappened()
{
svc.SendMessage();
}
}
2. 把选择抽象实现的责任移到消费者类的外部
需要把 EmailService 类的创建(svc = new EmailService( ))移到 NotificationSystem 类的外面。把依赖的创建移到使用这些依赖的类的外部,这就是控制反转模式!之所以这样命名,是因为反转的是依赖的创建,也正因为如此,才消除了消费者类对依赖创建的控制。控制反转(IoC)模式是抽象的,它只是表述应该从消费者类中移出依赖创建,而没有表述如何实现。下面我们将探讨用控制反转模式实现责任转移的两种常用方法:服务定位器和依赖注入(重点)。
设计模式 - 服务定位器
服务定位器模式是控制反转模式的一种实现方式,它通过一个称为服务定位器的外部组件来为需要依赖的组件提供依赖。服务定位器有时是一个具体的接口,为特定服务提供强类型的请求;有时又可能是一个泛型类型,可以提供任意类型的请求服务。
1. 强类型服务定位器
对应示例程序的强类型服务定位器可能有如下接口:
public interface IServiceLocator
{
IMessagingService GetMessagingService();
}
要用强类型的服务定位器重新编写 NotificationSystem 类,代码如下:
public class NotificationSystem
{
private IMessagingService svc;
public NotificationSystem(IServiceLocator locator)
{
svc = locator.GetMessagingService();
}
public void InterestingEventHappened()
{
svc.SendMessage();
}
}
上面的代码假设创建 NotificationSystem 实例的每个人都会访问服务定位器。这样做带来的便利是,如果应用程序通过服务定位器创建 NotificationSystem 实例,那么定位器将自身传递到 NotificationSystem 类的构造函数中。
2. 弱类型服务定位器
如果在某个应用中,强类型服务定位器的负面影响超过了它所带来的正面效应,可以考虑改用弱类型服务定位器:
public interface IServiceLocator
{
object GetService(Type serviceType);
}
这种变体更加灵活,因为它采用 Type 类型的参数,并返回一个 Object 类型的对象。当然,需要把调用 GetService 方法返回的结果转换为正确类型的对象。
使用弱类型服务定位器的 NotificationSystem 类的代码如下:
public class NotificationSystem
{
private IMessagingService svc;
public NotificationSystem(IServiceLocator locator)
{
svc = (IMessagingService)locator.GetService(typeof(IMessagingService));
}
public void InterestingEventHappened()
{
svc.SendMessage();
}
}
这样的代码并不简洁,这主要是因为调用 GetService 后需要转换类型。自从 .NET 2.0 引入泛型以来,就可以包含 GetService 方法的泛型版本:
public interface IServiceLocator
{
object GetService(Type serviceType);
TService GetService<TService>();
}
按照泛型方法的约定,它将返回一个已经转换为正确类型的对象,注意,返回的类型是 TService 而不是 Object,这使得 NotificationSystem 类的代码变得简洁一些:
public class NotificationSystem
{
private IMessagingService svc;
public NotificationSystem(IServiceLocator locator)
{
svc = locator.GetService<IMessagingService>();
}
public void InterestingEventHappened()
{
svc.SendMessage();
}
}
Object 版本的 GetService 仍然存在的意义,是因为并非每一个调用 API 的消费者在编译时都精确地知道它们将要调用的类型。现在这样做的负面影响是,它强制 IServiceLocator 接口必须实现两个几乎相同的方法!这些无谓的努力在 .NET 3.5 中被移除,因为一个新特性:扩展方法。
把扩展方法作为静态类的静态方法来编写,在它的第一个参数中利用特殊的 this 关键字来指定扩展方法要附加到的类型。
把 GetService 泛型方法分割成为扩展方法后,代码如下:
public interface IServiceLocator
{
object GetService(Type serviceType);
}
public static class ServiceLocatorExtensions
{
public static TService GetService<TService>(this IServiceLocator locator)
{
return (TService)locator.GetService(typeof(TService));
}
}
3. 服务定位器的利弊
服务定位器的用法比较简单:首先,我们从某个地方得到服务定位器,然后利用定位器查找依赖。可能在一个已知的(全局)位置找到服务定位器,或者通过我们的创建者获得服务定位器。尽管依赖关系有时会发生改变,但签名不会变,因为查找依赖唯一需要的就是定位器。持久签名带来好处的同时,也带来了弊端。它导致了组件需求的不透明性:使用组件的开发人员通过查看构造函数的签名不能知道服务要求的是什么。
需求的不透明性促使我们选择下一个反转控制模式:依赖注入。
设计模式 - 依赖注入
依赖注入(Dependency Injection,DI)是另一种控制反转模式的形式,它没有像服务定位器一样的中间对象。相反,组件以一种允许依赖的方式来编写,通常由构造函数参数或属性设置器来显式表示。
如果采用构造函数注入,NotificationSystem 类的代码将如下所示:
public class NotificationSystem
{
private IMessagingService svc;
public NotificationSystem(IMessagingService service)
{
this.svc = service;
}
public void InterestingEventHappened()
{
svc.SendMessage();
}
}
这段代码有 3 个显著优点:
- 极大的简化了构造函数的实现,组件总是期望创建它的类能够传递需要的依赖,而它只需存储 IMessagingService 接口的实例以便之后使用!(IMessagingService 的实现可以有各种版本,比如发送 email、发送文本消息,这就是消费者类调用接口而不是只调用 Email 类的好处)
- 这段代码减少了 NotificationSystem 类需要知道的信息量。在以前,NotificationSystem 既需要知道服务定位器,也需要知道它自己的依赖项,而现在只需要知道它自己的依赖项就行了。
- 需求的透明性。任何想创建 NotificationSystem 类实例的代码都能查看构造函数,并精确知道哪些内容是使用 NotificationSystem 类必需的。
属性注入(property injection)是一种不太常见的依赖注入方式,该方式通过设置对象上的公有属性而不是通过构造函数传递参数来注入依赖。
如果采用属性注入,NotificationSystem 类的代码将如下所示:
public class NotificationSystem
{
public IMessagingService MessagingService { get; set; }
public void InterestingEventHappened()
{
MessagingService.SendMessage();
}
}
现在,NotificationSystem 类希望任何消费者类都通过属性来提供依赖。现在的 InterestingEventHappened 方法可能会产生一个空引用对象的异常,如果它被调用时并没有提供服务依赖(没有对属性来赋值)。应做以下完善以确保使用服务之前已提供了服务依赖:
public void InterestingEventHappened()
{
if (this.MessagingService == null)
{
throw new InvalidOperationException("Please set MessagingService before calling InterestingEventHappened().");
}
MessagingService.SendMessage();
}
属性注入降低了透明性(无法通过构造函数知晓依赖),也比构造函数注入更容易产生错误,那么开发人员为何还要这样使用呢?究其原因,主要有 2 点:
- 如果依赖在某种意义上是真正可选的,即在消费者类不提供依赖时,也有相应的处理(比如说,在 MessagingService 的 get 中有一个默认的初始化动作)。
- 类的实例可能需要在我们还没有控制调用的构造函数的情况下被创建,这个原因并不太明显。
通常情况下,开发人员更倾向于使用构造函数注入!