编码最佳实践——依赖注入原则
我们在这个系列的前四篇文章中分别介绍了SOLID原则中的前四个原则,今天来介绍最后一个原则——依赖注入原则。依赖注入(DI)是一个很简单的概念,实现起来也很简单。但是简单却掩盖不了它的重要性,如果没有依赖注入,前面的介绍的SOLID技术原则都不可能实际应用。
控制反转(IoC)
人们在谈论依赖注入的时候,经常也会谈到另一个概念——控制反转(IoC)。按照大内老A的解释:“IoC主要体现了这样一种设计思想:通过将一组通用流程的控制权从应用转移到框架中以实现对流程的复用,并按照“好莱坞法则”实现应用程序的代码与框架之间的交互“。概念比较抽象,我们拆开解读一下。
我们要做的任何一件事情,无论大小,都可以分解为相应的步骤。所以任何一件事情都有其固定的流程。与现实问题域一样,解决方案域(程序实现)也是这样。所以IoC控制可以理解为“对流程的控制”。以HTTP请求处理的流程为例,在传统面向类库编程的时代,针对HTTP请求处理的流程牢牢控制在应用程序手中。在引入框架之后,请求处理的控制权转移到了框架手上。类库(Library)和框架(Framework)的不同之处在于,前者往往只是提供实现某种单一功能的API,而后者则针对一个目标任务对这些单一功能进行编排形成一个完整的流程,这个流程在一个引擎的驱动下自动执行。如此,所有使用此框架的程序都可以复用关于HTTP请求处理的流程。
在好莱坞,把简历递交给演艺公司后就只有回家等待。由演艺公司对整个娱乐项目的完全控制,演员只能被动式的接受电影公司的工作,在需要的环节中,完成自己的演出。“不要给我们打电话,我们会给你打电话(don‘t call us, we‘ll call you)”这是著名的好莱坞法则。
IoC完美地体现了这一法则,对于ASP.NET MVC应用开发来说,我们只需要按照约定规则(比如目录结构和命名等)定义相应的Controller类型和View文件就可以了,这就是所谓的“约定大于配置”。当ASP.NET MVC框架在进行处理请求的过程中,它会根据解析生成的路由参数定义为对应的Controller类型,并按照预定义的规则找到我们定义的Controller,然后自动创建并执行它。如果定义在当前Action方法需要呈现一个View,框架自身会根据预定义的目录约定找到我们定义的View文件,并对它实施动态编译和执行。整个流程处处体现了“框架Call应用”的好莱坞法则。
简单的说,控制反转(IoC)的过程就是一组通用流程的控制权从应用程序转移到框架中的过程,为的是实现流程的复用。但是有一个问题,被反转的仅仅是一个泛化的流程,在特定场景可能会有一些特殊的流程或者流程节点,此时就需要进行流程定制。定制一般是通过框架预留的扩展点进行的,比如ASP.NET中的HttpHandler和HttpModule,ASP.NET Core中的Middleware。
前面提到控制反转(IoC)是一种设计思想。所以控制反转(IoC)并不能解决某一类具体的问题。但是基于控制反转(IoC)思想的设计模式却可以,最简单直观的就是模板方法模式。该模式主张将一个可复用的工作流程或者由多个步骤组成的算法定义成模板方法,组成这个流程或者算法的步骤实现在相应的虚方法之中,模板方法根据按照预先编排的流程去调用这些虚方法。所有这些方法均定义在同一个类中,我们可以通过派生该类并重写相应的虚方法达到对流程定制的目的。
public class TemplateMethod
{
//流程编排
public void ABCD()
{
A();
B();
C();
D();
}
//步骤A
protected virtual void A() { }
//步骤B
protected virtual void B() { }
//步骤C
protected virtual void C() { }
//步骤D
protected virtual void D() { }
}
依赖注入(DI)
依赖注入(DI)也是架构在控制反转思想上的一种模式。在这里我们将提供的对象统称为“服务”、“服务对象”或者“服务实例”。在一个采用DI的应用中,在定义某个服务类型的时候,我们直接将依赖的服务采用相应的方式注入进来。按照“面向接口编程”的原则,被注入的最好是依赖服务的接口而非实现。正确的依赖注入对于项目的绝大多数代码都是不可见的,它们(注册代码)被局限在一个很小的代码范围内,通常是一个独立的程序集。
在应用启动的时候,会对所需的服务进行全局注册。服务一般都是针对接口进行注册的,服务注册信息的核心目的是为了在后续消费过程中能够根据接口创建或者提供对应的服务实例。按照“好莱坞法则”,应用只需要定义好所需的服务,服务实例的激活和调用则完全交给框架来完成,而框架则会采用一个独立的“容器(Container)”来提供所需的每一个服务实例。我们将这个被框架用来提供服务的容器称为“DI容器”,也由很多人将其称为“IoC容器”。所有的DI容器都符合注册、解析、释放模式。
依赖注入的三种注入方式
1.构造函数注入
public class TaskService
{
private ITaskOneRepository taskOneRepository;
private ITaskTwoRepository taskTwoRepository;
public TaskService(
ITaskOneRepository taskOneRepository,
ITaskTwoRepository taskTwoRepository)
{
this.taskOneRepository = taskOneRepository;
this.taskTwoRepository = taskTwoRepository;
}
}
优点:
- 在构造方法中体现出对其他类的依赖,一眼就能看出这个类需要其他那些类才能工作。
- 脱离了IOC框架,这个类仍然可以工作(穷人的依赖注入)。
- 一旦对象初始化成功了,这个对象的状态肯定是正确的。
缺点:
- 构造函数会有很多参数。
- 有些类是需要默认构造函数的,比如MVC框架的Controller类,一旦使用构造函数注入,就无法使用默认构造函数。
2.属性注入
public class TaskService
{
private ITaskRepository taskRepository;
private ISettings settings;
public TaskService(
ITaskRepository taskRepository,
ISettings settings)
{
this.taskRepository = taskRepository;
this.settings = settings;
}
public void OnLoad()
{
taskRepository.settings = settings;
}
}
优点:
- 在对象的整个生命周期内,可以随时动态的改变依赖。
- 非常灵活。
缺点:
- 对象在创建后,被设置依赖对象之前这段时间状态是不对的(从构造函数注入的依赖实例在类的整个生命周期内都可以使用,而从属性注入的依赖实例还能从类生命周期的某个中间点开始起作用)。
- 不直观,无法清晰地表示哪些属性是必须的。
3.方法注入
public class TaskRepository
{
private ISettings settings;
public void PrePare(ISettings settings)
{
this.settings = settings;
}
}
优点:
- 比较灵活。
缺点:
- 新加入依赖时会破坏原有的方法签名,如果这个方法已经被其他很多模块用到就很麻烦。
- 与构造方法注入一样,会有很多参数。
在这三种注入方式中,推荐使用构造函数注入。最重要的原因是服务应该是独立自治的,即使脱离了DI框架,这个服务应该仍然可以工作。构造函数注入就符合这一要求,即使脱离了DI框架,仍然可以手动注入依赖的服务。
依赖注入反模式 —— Service Locator
假设我们需要定义一个服务类型C,它依赖于另外两个服务A和B,后者对应的服务接口分别为IA和IB。如果当前应用中具有一个DI容器(Container),那么我们可以采用如下两种方式来定义这个服务类型C。
public class C : IC
{
public IA A { get; }
public IB B { get; }
public C(IA a, IB b)
{
A = a;
B = b;
}
public void Invoke()
{
a.Invoke();
b.Invoke();
}
}
public class C : IC
{
public Container Container { get; }
public C(Container container)
{
Container = container;
}
public void Invoke()
{
Container.GetService<IA>().Invoke();
Container.GetService<IB>().Invoke();
}
}
从表面上看,这两种方式并没有什么太大的区别。都解决了针对依赖服务的耦合问题,将针对服务实现依赖变成针对接口的依赖。但是,其实后一种方式并不是依赖注入模式,而是服务定位器反模式。因为看起来和依赖注入模式很相似,人们经常会忽视它给代码带来的破坏。
我们可以从“DI容器”和“Service Locator”被谁使用的角度来区分这两种设计模式的差别。DI容器的使用者是框架而不是应用程序,Service Locator的使用者是应用程序,应用程序利用它来提供服务实例。有时候,它是唯一能提供依赖注入钩子的方式。
那么Service Locator(服务定位器反模式)对代码造成了哪些破坏呢?
- 因为容器中的服务是全局注册的,所以DI容器是静态的,这会导致出现静态类或者服务中出现静态变量和字段。
- 服务定位器暴露了容器存在的信息。原因是服务定位器允许类检索任何对象,无论是否合适。这样违背了依赖注入的“好莱坞准则”,不要调用我们,我们会调用你。
- 服务定位器会直接委托Container实例来解析实例对象,这样会造成服务没有依赖的假象。但是服务肯定是有依赖的,不然为什么要从服务定位器获取它们呢。
虽然我们对服务定位器反模式提出了这么多批判,但是它还是非常常见。因为有时候根本没有从构造函数注入的任何机会,唯一的选择就是服务定位器。毕竟它肯定比不注入依赖要好,也比手动构造注入依赖要好。
总结
依赖注入(DI)是架构在控制反转(IoC)思想上的一种模式,所有的DI容器都符合注册、解析、释放模式。注入代码通常在一个独立的程序集,注入的最好是依赖服务的接口而非实现,服务实例的激活和调用则完全交给框架来完成。在依赖注入的三种注入方式中,推荐使用构造函数注入。另外在没有从构造函数注入的机会时,可以考虑选择服务定位器反模式。选择模式的原则是:依赖注入模式优于服务定位器反模式,优于手动构造注入依赖,优于不注入依赖。
参考
《C#敏捷开发实践》