作者:耗子
本文主要是厘清什么是控制反转,以及基于控制反转的概念衍生出的一些常用模式与工具。
什么是控制反转(Inversion of Control)
什么是控制反转,顾名思义就是反转了控制(看起来是句废话)。
要理解为什么要反转控制,就要从Robert C. Martin在2000年的论文Design Principles and Design Patterns中提到的SOLID原则说起。SOLID原则的提出是为了让OOP的软件设计更加易懂,灵活,可维护。
因此,我们基于SOLID来分析一下传统的应用程序的做法。
什么是SOLID?
- S: Single responsibility principle 职责单一原则
- O: Open/closed principle 开闭原则
- L: Liskov substitution principle 里氏替换原则
- I: Interface segregation principle 接口隔离原则
- D: Dependency inversion principle 依赖倒置原则
传统的应用程序当中,当组件A中用到了组件B的对象b,就会在A的代码中显式的new一个B的对象。
这里有个例子,我想做一个电商应用。这是个大项目,我们从最核心的下单发货开始。
业务也很简单,客户下单,然后发货的时候选择不同的物流公司。
设计有两点:
- 创建一个订单Order类,有个release方法,ship方法。
- 考虑到要通过不同的物流公司发货,创建一个IOrderShipper接口,不同的物流公司有不同的类去实现这个接口,这里符合了开闭原则,同时解耦了Order和OrderShipper。
// Order.cs
public class Order
{
public Order()
{
}
public void OrderReleasing()
{
// Release an order
}
public void OrderShipping()
{
// Ship an order
IOrderShipper orderShipper = new OrderShipper();
orderShipper.Ship(this);
}
}
// IOrderShipper.cs
public interface IOrderShipper
{
void Ship(Order order);
}
// OrderShipper.cs
public class OrderShipper : IOrderShipper
{
public void Ship(Order order)
{
// Ship an order
}
}
// Program.cs
class Program
{
static void Main(string[] args)
{
Order order = new Order();
order.OrderReleasing();
order.OrderShipping();
}
}
看到这样的代码,熟悉SOLID的话,肯定会说,这样不行,这里虽然使用了接口,但是并没有解耦,还违反了依赖倒置原则(DIP)。
再仔细琢磨一下,就会发现,除了以上两点,还违反了职责单一原则(SRP)。
可是我在设计的时候已经把职责拆分了啊,Order类只管订单,发货是OrderShipper类在管,为什么还是违反了职责单一呢?
因为Order对象在调用OrderShipper对象的ship方法时,直接在Order对象内部创建了一个OrderShipper,OrderShipper对象不管是创建的所有权,还是生命周期的管理都在Order内部。这样看来,Order对象为了用OrderShipper对象的方法,不仅要管自己,还要管OrderShipper对象,事情还挺多。
那我要发货怎么办呢,ship方法总是要调用的嘛。我们需要OrderShipper对象只是为了使用ship方法而已,至于OrderShipper对象本身,它是谁创建的,在哪里创建的,什么时候创建,我们根本不用关心。
将获得对象的过程改变一下,从主动创建一个对象变成拿一个已经有的对象用,由主动行为变为了被动行为,控制权颠倒过来了,这就是所谓的控制反转。从这个过程中我们可以发现,反转的不仅仅是对象的生成,对象的生命周期的控制也交出去了。
我们为了解决SRP的问题,也同时解决了DIP,皆大欢喜。
到这里还是有个疑问,我不在Order里创建OrderShipper对象完全没问题,但是这个对象总是要创建的,那在什么地方创建呢?要实现控制反转,在Martin Fowler 2004年谈IoC的文章Inversion of Control Containers and the Dependency Injection pattern里,他提到了一些设计模式可以实现,当然也有一些其他讨论IoC的文章提出了一些实现方法:
- Service locator pattern
- Dependency injection
- A contextualized lookup
- Template method design pattern
- Strategy design pattern
这么多,我们到底选哪个方法呢?经过了十多年大浪淘沙的实践,依赖注入(Dependency injection)最终被广泛应用。
依赖注入之所以脱颖而出,是因为使用它可以基本完全符合SOLID原则,其他几种设计模式到底在哪里违反了SOLID原则这里就不再讨论,有兴趣的可以自行查找。
这也是为什么我们现在一谈到控制反转,就将它与依赖注入划上了等号。至于这个等号是不是成立,我相信看到这里已经有个基本的判断了,从厘清概念的角度来说,不能,但是从实际应用的角度来说,能。
在具体谈依赖注入之前,想先提一下依赖注入容器。这并不是同一个概念,依赖注入是一个设计模式,而依赖注入容器是实现这种设计模式的一个方法。
当我们把控制反转的概念从一个普通的对象扩展到框架级别的时候,就意味着我们希望只关注业务本身,用到什么对象,它就准备好在那里,至于它们的生成,销毁,框架解决就可以。正如Martin在文章中阐述的,IoC并不是一个框架吸引人的优点,它应该是一个框架理应具备的基本功能。
以上就是控制反转这个概念的一些解释,控制反转并不是一个落地的实践,它的作用是引导我们写出好代码。在实践中,我们会更加关注另一个概念-依赖注入。
依赖注入的方式
回到具体的代码里,通过依赖注入的设计模式,我们应该在什么地方创建一个新的OrderShipper对象,并注入到Order对象中使用?
当前的类里不创建,就只能把创建的任务往外层丢,在我这个简单的代码示例里,创建新对象的位置就显然易见了,应用的入口-Program.cs里。那怎么注入到order里呢?当然是作为参数传入。
一个class能承接参数的只有三个地方,构造函数,属性的setter方法,普通方法。 这就对应着依赖注入的三种方法:
- Construction injection
- Property injection
- Method injection
这三种设计模式都有适用场景,具体适用场景怎么去判断也有一些讨论。简单来说,
- 当我们写一些应用程序时,比如我这个例子,构造函数注入是最佳选择,因此我们在谈依赖注入时,默认使用构造函数注入。
- Property injection可以提供默认的依赖对象,同时开放给外部注入一个依赖对象,因此在开发框架中会比较常见。
- 有些依赖对象变化比较多,就可以考虑用Method injection。
改写一下之前的那段代码:
// Order.cs
public class Order
{
private readonly IOrderShipper orderShipper;
public Order(IOrderShipper orderShipper)
{
this.orderShipper = orderShipper;
}
public void OrderReleasing()
{
}
public void OrderShipping()
{
orderShipper.Ship(this);
}
}
// Program.cs
class Program
{
static void Main(string[] args)
{
Order order = new Order(new OrderShipper());
order.OrderReleasing();
order.OrderShipping();
}
}
到这里,最简单的依赖注入已经完成了。
从我们这个最简单的例子可以看出,创建对象这个工作被推向了上一层,对于比较复杂的应用程序,这项工作会被不断往上层丢,最终集聚在程序的入口。这个结果是正是我们想要的,因为所有对象的创建都在一个地方(通常被称为Composition Root),方便管理。
Composition Root
Composition Root是个设计模式,它的出现就是为了解决我们在哪里,怎么样去组装所有的对象或者模块。
因此,所有的对象创建都必须在Composition Root里,同时Composition Root尽量靠近程序的入口。
实现Composition Root也有不同的方法。
在我们这个简单的例子中,只有两个对象,这行代码就是起了Composition Root的作用。
Order order = new Order(new OrderShipper());
当然实际使用中不会这么简单,一般都会涉及成百上千个类的组装,这种情况下我们就会用依赖注入容器去实现。
这就是一谈到控制反转,就会直接跳到依赖注入容器的原因。
依赖注入的作用
当使用依赖注入之后,我们就能很清楚的知道对象是怎么组装的,哪些对象之间有依赖关系。实际上,在Martin最早的文章里,依赖注入是和对象组合(Object composition)等价的。
现在会认为依赖注入有三个作用:
- Object Composition
- Lifetime Management
- Interception
第一和第二个作用都是显而易见的,第三点简单来说就是装饰模式。
具体到现在的这个例子中,现在是直接创建了一个OrderShipper对象注入到Order对象里。现在需求增加了,希望增添一些功能,比如对OrderShipper做一个验证。不管是从SRP还是OCP,这个验证的功能直接写在OrderShipper类里都不太合适。解决方案就是加一个类ValidateOrderShipper专门做验证,通过依赖注入很方便的添加了这个功能。
Order order = new Order(new ValidateOrderShipper(new OrderShipper()));
这样做的好处就是之前所有的类的都不会被更改,只用在创建对象的时候加一层而已。
第一和第二点发展出了现在常用的IoC容器,第三点就是给面向切片编程(Aspect-Oriented Programming, AOP)的概念铺路。